@hive-p2p/server 1.0.19 → 1.0.21
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/core/arbiter.mjs +125 -0
- package/core/config.mjs +226 -0
- package/core/crypto-codex.mjs +257 -0
- package/core/gossip.mjs +159 -0
- package/core/ice-offer-manager.mjs +181 -0
- package/core/node-services.mjs +129 -0
- package/core/node.mjs +177 -0
- package/core/peer-store.mjs +252 -0
- package/core/route-builder.mjs +176 -0
- package/core/topologist.mjs +254 -0
- package/core/unicast.mjs +155 -0
- package/index.mjs +3 -3
- package/libs/xxhash32.mjs +65 -0
- package/package.json +5 -5
- package/rendering/NetworkRenderer.mjs +734 -0
- package/rendering/renderer-options.mjs +85 -0
- package/rendering/renderer-stores.mjs +234 -0
- package/rendering/visualizer.css +138 -0
- package/rendering/visualizer.html +60 -0
- package/rendering/visualizer.mjs +254 -0
- package/services/clock-v2.mjs +196 -0
- package/services/clock.mjs +144 -0
- package/services/converter.mjs +64 -0
- package/services/cryptos.mjs +83 -0
package/core/gossip.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { CLOCK, GOSSIP, DISCOVERY } from './config.mjs';
|
|
2
|
+
import { xxHash32 } from '../libs/xxhash32.mjs';
|
|
3
|
+
|
|
4
|
+
export class GossipMessage { // TYPE DEFINITION
|
|
5
|
+
topic = 'gossip';
|
|
6
|
+
timestamp;
|
|
7
|
+
neighborsList;
|
|
8
|
+
HOPS;
|
|
9
|
+
senderId;
|
|
10
|
+
pubkey;
|
|
11
|
+
data;
|
|
12
|
+
signature;
|
|
13
|
+
signatureStart; // position in the serialized message where the signature starts
|
|
14
|
+
expectedEnd; // expected length of the serialized message
|
|
15
|
+
|
|
16
|
+
/** @param {string} topic @param {number} timestamp @param {string[]} neighborsList @param {number} HOPS @param {string} senderId @param {string} pubkey @param {string | Uint8Array | Object} data @param {string | undefined} signature @param {number} signatureStart @param {number} expectedEnd */
|
|
17
|
+
constructor(topic, timestamp, neighborsList, HOPS, senderId, pubkey, data, signature, signatureStart, expectedEnd) {
|
|
18
|
+
this.topic = topic; this.timestamp = timestamp; this.neighborsList = neighborsList;
|
|
19
|
+
this.HOPS = HOPS; this.senderId = senderId; this.pubkey = pubkey; this.data = data;
|
|
20
|
+
this.signature = signature; this.signatureStart = signatureStart; this.expectedEnd = expectedEnd;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** - 'BloomFilterCacheEntry' Definition
|
|
25
|
+
* @typedef {Object} BloomFilterCacheEntry
|
|
26
|
+
* @property {string} hash
|
|
27
|
+
* @property {string} senderId
|
|
28
|
+
* @property {string} topic
|
|
29
|
+
* @property {Uint8Array} serializedMessage
|
|
30
|
+
* @property {number} expiration
|
|
31
|
+
*/
|
|
32
|
+
class DegenerateBloomFilter {
|
|
33
|
+
cryptoCodex;
|
|
34
|
+
xxHash32UsageCount = 0;
|
|
35
|
+
/** @type {Record<string, number>} */
|
|
36
|
+
seenTimeouts = {}; // Map of message hashes to their expiration timestamps
|
|
37
|
+
|
|
38
|
+
/** @type {BloomFilterCacheEntry[]} */ cache = [];
|
|
39
|
+
cleanupFrequency = 100;
|
|
40
|
+
cleanupIn = 100;
|
|
41
|
+
cleanupDurationWarning = 10;
|
|
42
|
+
|
|
43
|
+
/** @param {import('./crypto-codex.mjs').CryptoCodex} cryptoCodex */
|
|
44
|
+
constructor(cryptoCodex) { this.cryptoCodex = cryptoCodex; }
|
|
45
|
+
|
|
46
|
+
// PUBLIC API
|
|
47
|
+
/** @param {'asc' | 'desc'} order */
|
|
48
|
+
getGossipHistoryByTime(order = 'asc') {
|
|
49
|
+
const lightenHistory = this.cache.map(e => ({ senderId: e.senderId, topic: e.topic, data: e.serializedMessage }));
|
|
50
|
+
return order === 'asc' ? lightenHistory : lightenHistory.reverse();
|
|
51
|
+
}
|
|
52
|
+
/** @param {Uint8Array} serializedMessage */
|
|
53
|
+
addMessage(serializedMessage) {
|
|
54
|
+
const n = CLOCK.time;
|
|
55
|
+
const { marker, neighLength, timestamp, dataLength, pubkey, associatedId } = this.cryptoCodex.readBufferHeader(serializedMessage);
|
|
56
|
+
if (n - timestamp > GOSSIP.EXPIRATION) return;
|
|
57
|
+
|
|
58
|
+
const hashableData = serializedMessage.subarray(0, 47 + neighLength + dataLength);
|
|
59
|
+
const h = xxHash32(hashableData);
|
|
60
|
+
this.xxHash32UsageCount++;
|
|
61
|
+
if (this.seenTimeouts[h]) return;
|
|
62
|
+
|
|
63
|
+
const topic = GOSSIP.MARKERS_BYTES[marker];
|
|
64
|
+
const senderId = associatedId;
|
|
65
|
+
const expiration = n + GOSSIP.CACHE_DURATION;
|
|
66
|
+
this.cache.push({ hash: h, senderId, topic, serializedMessage, timestamp, expiration });
|
|
67
|
+
this.seenTimeouts[h] = expiration;
|
|
68
|
+
|
|
69
|
+
if (--this.cleanupIn <= 0) this.#cleanupOldestEntries(n);
|
|
70
|
+
return { hash: h, isNew: !this.seenTimeouts[h] };
|
|
71
|
+
}
|
|
72
|
+
#cleanupOldestEntries(n = CLOCK.time) {
|
|
73
|
+
let firstValidIndex = -1;
|
|
74
|
+
for (let i = 0; i < this.cache.length; i++)
|
|
75
|
+
if (this.cache[i].expiration <= n) delete this.seenTimeouts[this.cache[i].hash];
|
|
76
|
+
else if (firstValidIndex === -1) firstValidIndex = i;
|
|
77
|
+
|
|
78
|
+
if (firstValidIndex > 0) this.cache = this.cache.slice(firstValidIndex);
|
|
79
|
+
else if (firstValidIndex === -1) this.cache = [];
|
|
80
|
+
this.cleanupIn = this.cleanupFrequency;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class Gossip {
|
|
85
|
+
/** @type {Record<string, Function[]>} */ callbacks = { message_handle: [] };
|
|
86
|
+
id; cryptoCodex; arbiter; peerStore; verbose; bloomFilter;
|
|
87
|
+
|
|
88
|
+
/** @param {string} selfId @param {import('./crypto-codex.mjs').CryptoCodex} cryptoCodex @param {import('./arbiter.mjs').Arbiter} arbiter @param {import('./peer-store.mjs').PeerStore} peerStore */
|
|
89
|
+
constructor(selfId, cryptoCodex, arbiter, peerStore, verbose = 0) {
|
|
90
|
+
this.id = selfId;
|
|
91
|
+
this.cryptoCodex = cryptoCodex;
|
|
92
|
+
this.arbiter = arbiter;
|
|
93
|
+
this.peerStore = peerStore;
|
|
94
|
+
this.verbose = verbose;
|
|
95
|
+
this.bloomFilter = new DegenerateBloomFilter(cryptoCodex);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {string} callbackType @param {Function} callback */
|
|
99
|
+
on(callbackType, callback) {
|
|
100
|
+
if (!this.callbacks[callbackType]) this.callbacks[callbackType] = [callback];
|
|
101
|
+
else this.callbacks[callbackType].push(callback);
|
|
102
|
+
}
|
|
103
|
+
/** Gossip a message to all connected peers > will be forwarded to all peers
|
|
104
|
+
* @param {string | Uint8Array | Object} data @param {string} topic @param {number} [HOPS] */
|
|
105
|
+
broadcastToAll(data, topic = 'gossip', HOPS) {
|
|
106
|
+
const hops = HOPS || GOSSIP.HOPS[topic] || GOSSIP.HOPS.default;
|
|
107
|
+
const serializedMessage = this.cryptoCodex.createGossipMessage(topic, data, hops, this.peerStore.neighborsList);
|
|
108
|
+
if (!this.bloomFilter.addMessage(serializedMessage)) return; // avoid sending duplicate messages
|
|
109
|
+
if (this.verbose > 3) console.log(`(${this.id}) Gossip ${topic}, to ${JSON.stringify(this.peerStore.neighborsList)}: ${data}`);
|
|
110
|
+
for (const peerId of this.peerStore.neighborsList) this.#broadcastToPeer(peerId, serializedMessage);
|
|
111
|
+
}
|
|
112
|
+
/** @param {string} targetId @param {any} serializedMessage */
|
|
113
|
+
#broadcastToPeer(targetId, serializedMessage) {
|
|
114
|
+
if (targetId === this.id) throw new Error(`Refusing to send a gossip message to self (${this.id}).`);
|
|
115
|
+
const transportInstance = this.peerStore.connected[targetId]?.transportInstance;
|
|
116
|
+
if (!transportInstance) return { success: false, reason: `Transport instance is not available for peer ${targetId}.` };
|
|
117
|
+
try { transportInstance.send(serializedMessage); }
|
|
118
|
+
catch (error) { this.peerStore.connected[targetId]?.close(); }
|
|
119
|
+
}
|
|
120
|
+
sendGossipHistoryToPeer(peerId) {
|
|
121
|
+
const gossipHistory = this.bloomFilter.getGossipHistoryByTime('asc');
|
|
122
|
+
for (const entry of gossipHistory) this.#broadcastToPeer(peerId, entry.data);
|
|
123
|
+
}
|
|
124
|
+
/** @param {string} from @param {Uint8Array} serialized @returns {void} */
|
|
125
|
+
async handleGossipMessage(from, serialized) {
|
|
126
|
+
if (this.arbiter.isBanished(from)) return this.verbose >= 3 ? console.info(`%cReceived gossip message from banned peer ${from}, ignoring.`, 'color: red;') : null;
|
|
127
|
+
if (!this.arbiter.countMessageBytes(from, serialized.byteLength, 'gossip')) return; // ignore if flooding/banished
|
|
128
|
+
for (const cb of this.callbacks.message_handle || []) cb(serialized); // Simulator counter before filtering
|
|
129
|
+
if (!this.bloomFilter.addMessage(serialized)) return; // already processed this message
|
|
130
|
+
|
|
131
|
+
const message = this.cryptoCodex.readGossipMessage(serialized);
|
|
132
|
+
if (!message) return this.arbiter.countPeerAction(from, 'WRONG_SERIALIZATION');
|
|
133
|
+
await this.arbiter.digestMessage(from, message, serialized);
|
|
134
|
+
if (this.arbiter.isBanished(from)) return; // ignore messages from banished peers
|
|
135
|
+
if (this.arbiter.isBanished(message.senderId)) return; // ignore messages from banished peers
|
|
136
|
+
|
|
137
|
+
const { topic, timestamp, neighborsList, HOPS, senderId, data } = message;
|
|
138
|
+
if (senderId === this.id) throw new Error(`#${this.id}#${from}# Received our own message back from peer ${from}.`);
|
|
139
|
+
|
|
140
|
+
if (this.verbose > 3) // LOGGING
|
|
141
|
+
if (senderId === from) console.log(`(${this.id}) Gossip ${topic} from ${senderId}: ${data}`);
|
|
142
|
+
else console.log(`(${this.id}) Gossip ${topic} from ${senderId} (by: ${from}): ${data}`);
|
|
143
|
+
|
|
144
|
+
this.peerStore.digestPeerNeighbors(senderId, neighborsList);
|
|
145
|
+
for (const cb of this.callbacks[topic] || []) cb(senderId, data, HOPS, message); // specific topic callback
|
|
146
|
+
if (HOPS < 1) return; // stop forwarding if HOPS is 0
|
|
147
|
+
|
|
148
|
+
const nCount = this.peerStore.neighborsList.length;
|
|
149
|
+
const trm = Math.max(1, nCount / GOSSIP.TRANSMISSION_RATE.NEIGHBOURS_PONDERATION);
|
|
150
|
+
const tRateBase = GOSSIP.TRANSMISSION_RATE[topic] || GOSSIP.TRANSMISSION_RATE.default;
|
|
151
|
+
const transmissionRate = Math.pow(tRateBase, trm);
|
|
152
|
+
const avoidTransmissionRate = nCount < GOSSIP.TRANSMISSION_RATE.MIN_NEIGHBOURS_TO_APPLY_PONDERATION;
|
|
153
|
+
const serializedToTransmit = this.cryptoCodex.decrementGossipHops(serialized);
|
|
154
|
+
for (const peerId of this.peerStore.neighborsList)
|
|
155
|
+
if (peerId === from) continue; // avoid sending back to sender
|
|
156
|
+
else if (!avoidTransmissionRate && Math.random() > transmissionRate) continue; // apply gossip transmission rate
|
|
157
|
+
else this.#broadcastToPeer(peerId, serializedToTransmit);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CLOCK, NODE, TRANSPORTS, LOG_CSS } from './config.mjs';
|
|
2
|
+
import { xxHash32 } from '../libs/xxhash32.mjs';
|
|
3
|
+
async function getWrtc() {
|
|
4
|
+
if (typeof globalThis.RTCPeerConnection !== 'undefined') return undefined;
|
|
5
|
+
return (await import('wrtc')).default;
|
|
6
|
+
}
|
|
7
|
+
const wrtc = await getWrtc();
|
|
8
|
+
|
|
9
|
+
/** - 'OfferObj' Definition
|
|
10
|
+
* @typedef {Object} OfferObj
|
|
11
|
+
* @property {number} timestamp
|
|
12
|
+
* @property {boolean} isUsed // => if true => should be deleted
|
|
13
|
+
* @property {number} sentCounter
|
|
14
|
+
* @property {Object} signal
|
|
15
|
+
* @property {import('simple-peer').Instance} offererInstance
|
|
16
|
+
* @property {boolean} isDigestingOneAnswer Flag to avoid multiple answers handling at the same time (DISCOVERY.LOOP_DELAY (2.5s) will be doubled (5s) between two answers handling)
|
|
17
|
+
* @property {Array<{peerId: string, signal: any, timestamp: number, used: boolean}>} answers
|
|
18
|
+
* @property {Record<string, boolean>} answerers key: peerId, value: true */
|
|
19
|
+
|
|
20
|
+
export class OfferManager { // Manages the creation of SDP offers and handling of answers
|
|
21
|
+
id;
|
|
22
|
+
verbose;
|
|
23
|
+
stunUrls;
|
|
24
|
+
|
|
25
|
+
/** @param {string} id @param {Array<{urls: string}>} stunUrls */
|
|
26
|
+
constructor(id, stunUrls, verbose = 0) { this.id = id; this.verbose = verbose; this.stunUrls = stunUrls; }
|
|
27
|
+
|
|
28
|
+
onSignalAnswer = null; // function(remoteId, signalData, offerHash)
|
|
29
|
+
onConnect = null; // function(remoteId, transportInstance)
|
|
30
|
+
|
|
31
|
+
/** @type {Record<number, import('simple-peer').Instance>} key: expiration timestamp */
|
|
32
|
+
offerInstanceByExpiration = {};
|
|
33
|
+
creatingOffer = false; // flag to avoid multiple simultaneous creations (shared between all offers)
|
|
34
|
+
offerCreationTimeout = null; // sequential creation timeout (shared between all offers)
|
|
35
|
+
offersToCreate = TRANSPORTS.MAX_SDP_OFFERS;
|
|
36
|
+
/** @type {Record<string, OfferObj>} key: offerHash **/ offers = {};
|
|
37
|
+
|
|
38
|
+
tick() { // called in peerStore to avoid multiple intervals
|
|
39
|
+
const now = CLOCK.time;
|
|
40
|
+
// CLEAR EXPIRED CREATOR OFFER INSTANCES
|
|
41
|
+
for (const expiration in this.offerInstanceByExpiration) {
|
|
42
|
+
const instance = this.offerInstanceByExpiration[expiration];
|
|
43
|
+
if (now < expiration) continue; // not expired yet
|
|
44
|
+
instance?.destroy();
|
|
45
|
+
delete this.offerInstanceByExpiration[expiration];
|
|
46
|
+
this.creatingOffer = false; // release flag
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// CLEAR USED AND EXPIRED OFFERS
|
|
50
|
+
for (const hash in this.offers) {
|
|
51
|
+
const offer = this.offers[hash];
|
|
52
|
+
if (offer.offererInstance.destroyed) { delete this.offers[hash]; continue; } // offerer destroyed
|
|
53
|
+
if (offer.isUsed) { delete this.offers[hash]; continue; } // used offer => remove it (handled by peerStore)
|
|
54
|
+
if (offer.timestamp + TRANSPORTS.SDP_OFFER_EXPIRATION > now) continue; // not expired yet
|
|
55
|
+
offer.offererInstance?.destroy();
|
|
56
|
+
delete this.offers[hash];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// TRY TO USE AVAILABLE ANSWERS
|
|
60
|
+
let offerCount = 0;
|
|
61
|
+
for (const hash in this.offers) {
|
|
62
|
+
offerCount++; // [live at the first line of the loop] used just below -> avoid Object.keys() call
|
|
63
|
+
const offer = this.offers[hash];
|
|
64
|
+
if (offer.isDigestingOneAnswer) { offer.isDigestingOneAnswer = false; continue; }
|
|
65
|
+
if (offer.offererInstance.destroyed) continue; // offerer destroyed
|
|
66
|
+
|
|
67
|
+
const unusedAnswers = offer.answers.filter(a => !a.used);
|
|
68
|
+
if (!unusedAnswers.length) continue; // no answers available
|
|
69
|
+
|
|
70
|
+
const newestAnswer = unusedAnswers.reduce((a, b) => a.timestamp > b.timestamp ? a : b);
|
|
71
|
+
if (!newestAnswer) continue; // all answers are used
|
|
72
|
+
|
|
73
|
+
newestAnswer.used = true;
|
|
74
|
+
const receivedSince = now - newestAnswer.timestamp;
|
|
75
|
+
if (receivedSince > NODE.CONNECTION_UPGRADE_TIMEOUT / 2) continue; // remote peer will break the connection soon, don't use this answer
|
|
76
|
+
offer.offererInstance.signal(newestAnswer.signal);
|
|
77
|
+
offer.isDigestingOneAnswer = true;
|
|
78
|
+
if (this.verbose > 2) console.log(`(${this.id}) Using answer from ${newestAnswer.peerId} for offer ${hash} (received since ${receivedSince} ms)`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.creatingOffer) return; // already creating one or unable to send
|
|
82
|
+
if (offerCount >= this.offersToCreate) return; // already have enough offers
|
|
83
|
+
|
|
84
|
+
// CREATE NEW OFFER
|
|
85
|
+
this.creatingOffer = true;
|
|
86
|
+
const expiration = now + (TRANSPORTS.SIGNAL_CREATION_TIMEOUT || 8_000);
|
|
87
|
+
const instance = this.#createOffererInstance(expiration);
|
|
88
|
+
this.offerInstanceByExpiration[expiration] = instance;
|
|
89
|
+
};
|
|
90
|
+
#createOffererInstance(expiration) {
|
|
91
|
+
const iceCompleteTimeout = TRANSPORTS.ICE_COMPLETE_TIMEOUT || 1_000;
|
|
92
|
+
const instance = new TRANSPORTS.PEER({ initiator: true, trickle: false, iceCompleteTimeout, wrtc, config: { iceServers: this.stunUrls } });
|
|
93
|
+
instance.on('error', error => this.#onError(error));
|
|
94
|
+
instance.on('signal', data => { // trickle: false => only one signal event with the full offer
|
|
95
|
+
const { candidate, type } = data; // with trickle, we need to adapt the approach.
|
|
96
|
+
if (!data || candidate) throw new Error('Unexpected signal data from offerer instance: ' + JSON.stringify(data));
|
|
97
|
+
if (type !== 'offer') throw new Error('Unexpected signal type from offerer instance: ' + type);
|
|
98
|
+
|
|
99
|
+
// OFFER READY
|
|
100
|
+
delete this.offerInstanceByExpiration[expiration];
|
|
101
|
+
const offerHash = xxHash32(JSON.stringify(data)); // UN PEU BLOQUE ICI (connect on voudrait identifer le peer)
|
|
102
|
+
instance.on('connect', () => { // cb > peerStore > Node > Node.#onConnect()
|
|
103
|
+
if (this.offers[offerHash]) this.offers[offerHash].isUsed = true;
|
|
104
|
+
this.onConnect(undefined, instance);
|
|
105
|
+
});
|
|
106
|
+
this.offers[offerHash] = { timestamp: CLOCK.time, sentCounter: 0, signal: data, offererInstance: instance, answers: [], answerers: {}, isUsed: false };
|
|
107
|
+
this.creatingOffer = false; // release flag
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return instance;
|
|
111
|
+
}
|
|
112
|
+
/** @param {Error} error @param {string} incl @param {number} level @param {'includes' | 'startsWith'} searchMode (Prefer 'startsWith' for performance) */
|
|
113
|
+
#logAndOrIgnore(error, incl = '', level = 2, searchMode = 'includes') { // if false => log it fully, if true => ignore it (message logged or ignored based on level)
|
|
114
|
+
if (searchMode[0] === 'i' && !error.message.includes(incl)) return false;
|
|
115
|
+
else if (!error.message.startsWith(incl)) return false;
|
|
116
|
+
if (this.verbose >= level) console.info(`%cOfferManager => ${error.message}`, LOG_CSS.PEER_STORE);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
#onError = (error) => {
|
|
120
|
+
if (this.verbose < 1) return; // avoid logging
|
|
121
|
+
// PRODUCTION (SimplePeer ERRORS) --|
|
|
122
|
+
if (this.#logAndOrIgnore(error, 'Ice connection failed', 2)) return;
|
|
123
|
+
if (this.#logAndOrIgnore(error, 'Connection failed', 2)) return;
|
|
124
|
+
// --PRODUCTION ----------------- --|
|
|
125
|
+
|
|
126
|
+
if (this.#logAndOrIgnore(error, 'Remote transport instance', 3, 'startsWith')) return;
|
|
127
|
+
if (this.#logAndOrIgnore(error, 'Simulated failure', 4, 'startsWith')) return;
|
|
128
|
+
if (this.#logAndOrIgnore(error, 'No peer found', 4, 'startsWith')) return;
|
|
129
|
+
if (this.#logAndOrIgnore(error, 'Missing transport instance', 2, 'startsWith')) return;
|
|
130
|
+
if (this.#logAndOrIgnore(error, 'Failed to create answer', 2, 'startsWith')) return;
|
|
131
|
+
if (this.#logAndOrIgnore(error, 'Transport instance', 3, 'startsWith')) return;
|
|
132
|
+
if (this.#logAndOrIgnore(error, 'cannot signal after peer is destroyed', 3, 'startsWith')) return;
|
|
133
|
+
if (this.#logAndOrIgnore(error, 'No pending', 3)) return;
|
|
134
|
+
if (this.#logAndOrIgnore(error, 'is already linked', 3)) return;
|
|
135
|
+
if (this.#logAndOrIgnore(error, 'There is already a pending', 3)) return;
|
|
136
|
+
if (this.#logAndOrIgnore(error, 'closed the connection', 3)) return;
|
|
137
|
+
if (this.#logAndOrIgnore(error, 'No transport instance found for id:', 3)) return;
|
|
138
|
+
|
|
139
|
+
if (this.verbose > 0) console.error(`transportInstance ERROR => `, error.stack);
|
|
140
|
+
};
|
|
141
|
+
/** @param {string} remoteId @param {{type: 'answer', sdp: Record<string, string>}} signal @param {string} offerHash @param {number} timestamp receptionTimestamp */
|
|
142
|
+
addSignalAnswer(remoteId, signal, offerHash, timestamp) {
|
|
143
|
+
if (!signal || signal.type !== 'answer' || !offerHash) return; // ignore non-answers or missing offerHash
|
|
144
|
+
if (!this.offers[offerHash] || this.offers[offerHash].answerers[remoteId]) return; // already have an answer from this peerId
|
|
145
|
+
this.offers[offerHash].answerers[remoteId] = true; // mark as having answered - one answer per peerId
|
|
146
|
+
this.offers[offerHash].answers.push({ peerId: remoteId, signal, timestamp });
|
|
147
|
+
if (this.verbose > 3) console.log(`(OfferManager) Added answer from ${remoteId} for offer ${offerHash}`);
|
|
148
|
+
}
|
|
149
|
+
/** @param {string} remoteId @param {{type: 'offer' | 'answer', sdp: Record<string, string>}} signal @param {string} [offerHash] offer only */
|
|
150
|
+
getTransportInstanceForSignal(remoteId, signal, offerHash) {
|
|
151
|
+
try {
|
|
152
|
+
if (!signal || !signal.type || !signal.sdp) throw new Error('Wrong remote SDP provided');
|
|
153
|
+
|
|
154
|
+
const { type, sdp } = signal;
|
|
155
|
+
if (type !== 'offer' && type !== 'answer') throw new Error('Invalid remote SDP type');
|
|
156
|
+
if (type === 'offer' && !sdp) throw new Error('No SDP in the remote SDP offer');
|
|
157
|
+
if (type === 'answer' && !sdp) throw new Error('No SDP in the remote SDP answer');
|
|
158
|
+
|
|
159
|
+
if (type === 'answer') { // NEED TO FIND THE PENDING OFFERER INSTANCE
|
|
160
|
+
const instance = offerHash ? this.offers[offerHash]?.offererInstance : null;
|
|
161
|
+
if (!instance) throw new Error('No pending offer found for the given offer hash to accept the answer');
|
|
162
|
+
return instance;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// type === 'offer' => CREATE ANSWERER INSTANCE
|
|
166
|
+
const iceCompleteTimeout = TRANSPORTS.ICE_COMPLETE_TIMEOUT || 1_000;
|
|
167
|
+
const instance = new TRANSPORTS.PEER({ initiator: false, trickle: false, iceCompleteTimeout, wrtc, config: { iceServers: this.stunUrls } });
|
|
168
|
+
instance.on('error', (error) => this.#onError(error));
|
|
169
|
+
instance.on('signal', (data) => this.onSignalAnswer(remoteId, data, offerHash));
|
|
170
|
+
instance.on('connect', () => this.onConnect(remoteId, instance));
|
|
171
|
+
return instance;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.message.startsWith('No pending offer found') && this.verbose < 2) return null; // avoid logging
|
|
174
|
+
if (this.verbose > 1 && error.message.startsWith('No pending offer found')) return console.info(`%c${error.message}`, LOG_CSS.PEER_STORE);
|
|
175
|
+
if (this.verbose > 0) console.error(error.stack);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
destroy() {
|
|
179
|
+
for (const offerHash in this.offers) this.offers[offerHash].offererInstance?.destroy();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { SIMULATION, NODE, SERVICE, TRANSPORTS, DISCOVERY, LOG_CSS } from './config.mjs';
|
|
2
|
+
import { PeerConnection } from './peer-store.mjs';
|
|
3
|
+
import { Converter } from '../services/converter.mjs';
|
|
4
|
+
const dgram = !NODE.IS_BROWSER ? await import('dgram') : null;
|
|
5
|
+
/*const dgram = !NODE.IS_BROWSER ?
|
|
6
|
+
await import('dgram').catch(() => null) :
|
|
7
|
+
null;*/
|
|
8
|
+
|
|
9
|
+
export class NodeServices {
|
|
10
|
+
id;
|
|
11
|
+
verbose;
|
|
12
|
+
maxKick;
|
|
13
|
+
peerStore;
|
|
14
|
+
cryptoCodex;
|
|
15
|
+
/** @type {string | undefined} WebSocket URL (public node only) */ publicUrl;
|
|
16
|
+
|
|
17
|
+
/** @param {import('./crypto-codex.mjs').CryptoCodex} cryptoCodex @param {import('./peer-store.mjs').PeerStore} peerStore */
|
|
18
|
+
constructor(cryptoCodex, peerStore, maxKick = 3, verbose = 1) {
|
|
19
|
+
this.id = cryptoCodex.id;
|
|
20
|
+
this.verbose = verbose;
|
|
21
|
+
this.maxKick = maxKick;
|
|
22
|
+
this.peerStore = peerStore;
|
|
23
|
+
this.cryptoCodex = cryptoCodex;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
start(domain = 'localhost', port = SERVICE.PORT) {
|
|
27
|
+
this.publicUrl = `ws://${domain}:${port}`;
|
|
28
|
+
this.#startWebSocketServer(domain, port);
|
|
29
|
+
if (!SIMULATION.USE_TEST_TRANSPORTS) this.#startSTUNServer(domain, port + 1);
|
|
30
|
+
}
|
|
31
|
+
freePublicNodeByKickingPeers() {
|
|
32
|
+
const maxKick = Math.min(this.maxKick, this.peerStore.neighborsList.length - DISCOVERY.TARGET_NEIGHBORS_COUNT);
|
|
33
|
+
if (maxKick <= 0) return; // nothing to do
|
|
34
|
+
|
|
35
|
+
let kicked = 0;
|
|
36
|
+
const delay = SERVICE.AUTO_KICK_DELAY;
|
|
37
|
+
for (const peerId in this.peerStore.connected) {
|
|
38
|
+
const conn = this.peerStore.connected[peerId];
|
|
39
|
+
const nonPublicNeighborsCount = this.peerStore.getUpdatedPeerConnectionsCount(peerId, false);
|
|
40
|
+
if (nonPublicNeighborsCount > DISCOVERY.TARGET_NEIGHBORS_COUNT) { // OVER CONNECTED
|
|
41
|
+
this.peerStore.kickPeer(peerId, SERVICE.AUTO_KICK_DURATION, 'freePublicNode');
|
|
42
|
+
if (this.peerStore.neighborsList.length <= DISCOVERY.TARGET_NEIGHBORS_COUNT) break;
|
|
43
|
+
else continue; // Don't count in maxKick
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (conn.getConnectionDuration() < (nonPublicNeighborsCount > 2 ? delay : delay * 2)) continue;
|
|
47
|
+
this.peerStore.kickPeer(peerId, SERVICE.AUTO_KICK_DURATION, 'freePublicNode');
|
|
48
|
+
if (++kicked >= maxKick) break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
#startWebSocketServer(domain = 'localhost', port = SERVICE.PORT) {
|
|
52
|
+
this.wsServer = new TRANSPORTS.WS_SERVER({ port, host: domain });
|
|
53
|
+
this.wsServer.on('error', (error) => console.error(`WebSocket error on Node #${this.id}:`, error));
|
|
54
|
+
this.wsServer.on('connection', (ws) => {
|
|
55
|
+
ws.on('close', () => { if (remoteId) for (const cb of this.peerStore.callbacks.disconnect) cb(remoteId, 'in'); });
|
|
56
|
+
ws.on('error', (error) => console.error(`WebSocket error on Node #${this.id} with peer ${remoteId}:`, error.stack));
|
|
57
|
+
|
|
58
|
+
let remoteId;
|
|
59
|
+
ws.on('message', (data) => { // When peer proves his id, we can handle data normally
|
|
60
|
+
if (remoteId) for (const cb of this.peerStore.callbacks.data) cb(remoteId, data);
|
|
61
|
+
else { // FIRST MESSAGE SHOULD BE HANDSHAKE WITH ID
|
|
62
|
+
const d = new Uint8Array(data); if (d[0] > 127) return; // not unicast, ignore
|
|
63
|
+
const { route, type, neighborsList } = this.cryptoCodex.readUnicastMessage(d) || {};
|
|
64
|
+
if (type !== 'handshake' || route.length !== 2 || route[1] !== this.id) return;
|
|
65
|
+
|
|
66
|
+
remoteId = route[0];
|
|
67
|
+
this.peerStore.digestPeerNeighbors(remoteId, neighborsList); // Update known store
|
|
68
|
+
this.peerStore.connecting[remoteId]?.out?.close(); // close outgoing connection if any
|
|
69
|
+
if (!this.peerStore.connecting[remoteId]) this.peerStore.connecting[remoteId] = {};
|
|
70
|
+
this.peerStore.connecting[remoteId].in = new PeerConnection(remoteId, ws, 'in', true);
|
|
71
|
+
for (const cb of this.peerStore.callbacks.connect) cb(remoteId, 'in');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
#startSTUNServer(host = 'localhost', port = SERVICE.PORT + 1) {
|
|
77
|
+
this.stunServer = dgram.createSocket('udp4');
|
|
78
|
+
this.stunServer.on('message', (msg, rinfo) => {
|
|
79
|
+
if (this.verbose > 2) console.log(`%cSTUN message from ${rinfo.address}:${rinfo.port} - ${msg.toString('hex')}`, LOG_CSS.SERVICE);
|
|
80
|
+
if (!this.#isValidSTUNRequest(msg)) return;
|
|
81
|
+
this.stunServer.send(this.#buildSTUNResponse(msg, rinfo), rinfo.port, rinfo.address);
|
|
82
|
+
});
|
|
83
|
+
this.stunServer.bind(port, host);
|
|
84
|
+
}
|
|
85
|
+
#isValidSTUNRequest(msg) {
|
|
86
|
+
if (msg.length < 20) return false;
|
|
87
|
+
const messageType = msg.readUInt16BE(0);
|
|
88
|
+
const magicCookie = msg.readUInt32BE(4);
|
|
89
|
+
return messageType === 0x0001 && magicCookie === 0x2112A442;
|
|
90
|
+
}
|
|
91
|
+
#buildSTUNResponse(request, rinfo) {
|
|
92
|
+
const transactionId = request.subarray(8, 20); // copy the 12 bytes
|
|
93
|
+
|
|
94
|
+
// Header : Success Response (0x0101) + length + magic + transaction
|
|
95
|
+
const response = Buffer.allocUnsafe(32); // 20 header + 12 attribute
|
|
96
|
+
response.writeUInt16BE(0x0101, 0); // Binding Success Response
|
|
97
|
+
response.writeUInt16BE(12, 2); // Message Length (12 bytes d'attributs)
|
|
98
|
+
response.writeUInt32BE(0x2112A442, 4); // Magic Cookie
|
|
99
|
+
transactionId.copy(response, 8); // Transaction ID
|
|
100
|
+
|
|
101
|
+
// Attribut MAPPED-ADDRESS (8 bytes)
|
|
102
|
+
response.writeUInt16BE(0x0001, 20); // Type: MAPPED-ADDRESS
|
|
103
|
+
response.writeUInt16BE(8, 22); // Length: 8 bytes
|
|
104
|
+
response.writeUInt16BE(0x0001, 24); // Family: IPv4
|
|
105
|
+
response.writeUInt16BE(rinfo.port, 26); // Port
|
|
106
|
+
response.writeUInt32BE(Converter.ipToInt(rinfo.address), 28); // IP
|
|
107
|
+
|
|
108
|
+
if (this.verbose > 2) console.log(`%cSTUN Response: client will discover IP ${rinfo.address}:${rinfo.port}`, 'color: green;');
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
/** @param {Array<{id: string, publicUrl: string}>} bootstraps */
|
|
112
|
+
static deriveSTUNServers(bootstraps, includesCentralized = false) {
|
|
113
|
+
/** @type {Array<{urls: string}>} */
|
|
114
|
+
const stunUrls = [];
|
|
115
|
+
for (const b of bootstraps) {
|
|
116
|
+
const domain = b.publicUrl.split(':')[1].replace('//', '');
|
|
117
|
+
const port = parseInt(b.publicUrl.split(':')[2]) + 1;
|
|
118
|
+
stunUrls.push({ urls: `stun:${domain}:${port}` });
|
|
119
|
+
}
|
|
120
|
+
if (!includesCentralized) return stunUrls;
|
|
121
|
+
|
|
122
|
+
// CENTRALIZED STUN SERVERS FALLBACK (GOOGLE) - OPTIONAL
|
|
123
|
+
this.stunUrls.push({ urls: 'stun:stun.l.google.com:5349' });
|
|
124
|
+
this.stunUrls.push({ urls: 'stun:stun.l.google.com:19302' });
|
|
125
|
+
this.stunUrls.push({ urls: 'stun:stun1.l.google.com:3478' });
|
|
126
|
+
this.stunUrls.push({ urls: 'stun:stun1.l.google.com:5349' });
|
|
127
|
+
return stunUrls;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/core/node.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { CLOCK, SIMULATION, NODE, SERVICE, DISCOVERY } from './config.mjs';
|
|
2
|
+
import { Arbiter } from './arbiter.mjs';
|
|
3
|
+
import { OfferManager } from './ice-offer-manager.mjs';
|
|
4
|
+
import { PeerStore } from './peer-store.mjs';
|
|
5
|
+
import { UnicastMessager } from './unicast.mjs';
|
|
6
|
+
import { Gossip } from './gossip.mjs';
|
|
7
|
+
import { Topologist } from './topologist.mjs';
|
|
8
|
+
import { CryptoCodex } from './crypto-codex.mjs';
|
|
9
|
+
import { NodeServices } from './node-services.mjs';
|
|
10
|
+
|
|
11
|
+
/** Create and start a new PublicNode instance.
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {Array<{id: string, publicUrl: string}>} options.bootstraps List of bootstrap nodes used as P2P network entry
|
|
14
|
+
* @param {boolean} [options.autoStart] If true, the node will automatically start after creation (default: true)
|
|
15
|
+
* @param {CryptoCodex} [options.cryptoCodex] Identity of the node; if not provided, a new one will be generated
|
|
16
|
+
* @param {string} [options.domain] If provided, the node will operate as a public node and start necessary services (e.g., WebSocket server)
|
|
17
|
+
* @param {number} [options.port] If provided, the node will listen on this port (default: SERVICE.PORT)
|
|
18
|
+
* @param {number} [options.verbose] Verbosity level for logging (default: NODE.DEFAULT_VERBOSE) */
|
|
19
|
+
export async function createPublicNode(options) {
|
|
20
|
+
const verbose = options.verbose !== undefined ? options.verbose : NODE.DEFAULT_VERBOSE;
|
|
21
|
+
const domain = options.domain || undefined;
|
|
22
|
+
const codex = options.cryptoCodex || new CryptoCodex(undefined, verbose);
|
|
23
|
+
if (!codex.publicKey) await codex.generate(domain ? true : false);
|
|
24
|
+
|
|
25
|
+
const node = new Node(codex, options.bootstraps || [], verbose);
|
|
26
|
+
if (domain) {
|
|
27
|
+
node.services = new NodeServices(codex, node.peerStore, undefined, verbose);
|
|
28
|
+
node.services.start(domain, options.port || SERVICE.PORT);
|
|
29
|
+
node.topologist.services = node.services;
|
|
30
|
+
}
|
|
31
|
+
if (options.autoStart !== false) await node.start();
|
|
32
|
+
return node;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Create and start a new Node instance.
|
|
36
|
+
* @param {Object} options
|
|
37
|
+
* @param {Array<{id: string, publicUrl: string}>} options.bootstraps List of bootstrap nodes used as P2P network entry
|
|
38
|
+
* @param {CryptoCodex} [options.cryptoCodex] Identity of the node; if not provided, a new one will be generated
|
|
39
|
+
* @param {boolean} [options.autoStart] If true, the node will automatically start after creation (default: true)
|
|
40
|
+
* @param {number} [options.verbose] Verbosity level for logging (default: NODE.DEFAULT_VERBOSE) */
|
|
41
|
+
export async function createNode(options = {}) {
|
|
42
|
+
const verbose = options.verbose !== undefined ? options.verbose : NODE.DEFAULT_VERBOSE;
|
|
43
|
+
const codex = options.cryptoCodex || new CryptoCodex(undefined, verbose);
|
|
44
|
+
if (!codex.publicKey) await codex.generate(false);
|
|
45
|
+
|
|
46
|
+
const node = new Node(codex, options.bootstraps || [], verbose);
|
|
47
|
+
if (options.autoStart !== false) await node.start();
|
|
48
|
+
return node;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class Node {
|
|
52
|
+
started = false;
|
|
53
|
+
id; cryptoCodex; verbose; arbiter;
|
|
54
|
+
/** class managing ICE offers */ offerManager;
|
|
55
|
+
/** class managing network connections */ peerStore;
|
|
56
|
+
/** class who manage direct messages */ messager;
|
|
57
|
+
/** class who manage gossip messages */ gossip;
|
|
58
|
+
/** class managing network connections */ topologist;
|
|
59
|
+
/** @type {NodeServices | undefined} */ services;
|
|
60
|
+
|
|
61
|
+
/** Initialize a new P2P node instance, use .start() to init topologist
|
|
62
|
+
* @param {CryptoCodex} cryptoCodex - Identity of the node.
|
|
63
|
+
* @param {Array<Record<string, string>>} bootstraps List of bootstrap nodes used as P2P network entry */
|
|
64
|
+
constructor(cryptoCodex, bootstraps = [], verbose = NODE.DEFAULT_VERBOSE) {
|
|
65
|
+
this.verbose = verbose;
|
|
66
|
+
if (this.topologist?.services) this.topologist.services.verbose = verbose;
|
|
67
|
+
this.cryptoCodex = cryptoCodex;
|
|
68
|
+
this.id = this.cryptoCodex.id;
|
|
69
|
+
const stunUrls = NodeServices.deriveSTUNServers(bootstraps || []);
|
|
70
|
+
this.offerManager = new OfferManager(this.id, stunUrls, verbose);
|
|
71
|
+
this.arbiter = new Arbiter(this.id, cryptoCodex, verbose);
|
|
72
|
+
this.peerStore = new PeerStore(this.id, this.cryptoCodex, this.offerManager, this.arbiter, verbose);
|
|
73
|
+
this.messager = new UnicastMessager(this.id, this.cryptoCodex, this.arbiter, this.peerStore, verbose);
|
|
74
|
+
this.gossip = new Gossip(this.id, this.cryptoCodex, this.arbiter, this.peerStore, verbose);
|
|
75
|
+
this.topologist = new Topologist(this.id, this.gossip, this.messager, this.peerStore, bootstraps || []);
|
|
76
|
+
const { arbiter, peerStore, messager, gossip, topologist } = this;
|
|
77
|
+
|
|
78
|
+
// SETUP TRANSPORTS LISTENERS
|
|
79
|
+
peerStore.on('signal', (peerId, data) => this.sendMessage(peerId, data, 'signal_answer')); // answer created => send it to offerer
|
|
80
|
+
peerStore.on('connect', (peerId, direction) => this.#onConnect(peerId, direction));
|
|
81
|
+
peerStore.on('disconnect', (peerId, direction) => this.#onDisconnect(peerId, direction));
|
|
82
|
+
peerStore.on('data', (peerId, data) => this.#onData(peerId, data));
|
|
83
|
+
|
|
84
|
+
// UNICAST LISTENERS
|
|
85
|
+
messager.on('signal_answer', (senderId, data) => topologist.handleIncomingSignal(senderId, data));
|
|
86
|
+
messager.on('signal_offer', (senderId, data) => topologist.handleIncomingSignal(senderId, data));
|
|
87
|
+
|
|
88
|
+
// GOSSIP LISTENERS
|
|
89
|
+
gossip.on('signal_offer', (senderId, data, HOPS) => topologist.handleIncomingSignal(senderId, data, HOPS));
|
|
90
|
+
|
|
91
|
+
if (verbose > 2) console.log(`Node initialized: ${this.id}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// PRIVATE METHODS
|
|
95
|
+
/** @param {string} peerId @param {'in' | 'out'} direction */
|
|
96
|
+
#onConnect = (peerId, direction) => {
|
|
97
|
+
const remoteIsPublic = this.cryptoCodex.isPublicNode(peerId);
|
|
98
|
+
if (this.publicUrl) return; // public node do not need to do anything special on connect
|
|
99
|
+
if (this.verbose > ((this.publicUrl || remoteIsPublic) ? 3 : 2)) console.log(`(${this.id}) ${direction === 'in' ? 'Incoming' : 'Outgoing'} connection established with peer ${peerId}`);
|
|
100
|
+
const isHandshakeInitiator = remoteIsPublic || direction === 'in';
|
|
101
|
+
if (isHandshakeInitiator) this.sendMessage(peerId, this.id, 'handshake'); // send it in both case, no doubt...
|
|
102
|
+
|
|
103
|
+
const isHoverNeighbored = this.peerStore.neighborsList.length >= DISCOVERY.TARGET_NEIGHBORS_COUNT + this.halfTarget;
|
|
104
|
+
const dispatchEvents = () => {
|
|
105
|
+
//this.sendMessage(peerId, this.id, 'handshake'); // send it in both case, no doubt...
|
|
106
|
+
if (DISCOVERY.ON_CONNECT_DISPATCH.OVER_NEIGHBORED && isHoverNeighbored)
|
|
107
|
+
this.broadcast([], 'over_neighbored'); // inform my neighbors that I am over neighbored
|
|
108
|
+
if (DISCOVERY.ON_CONNECT_DISPATCH.SHARE_HISTORY)
|
|
109
|
+
if (this.peerStore.getUpdatedPeerConnectionsCount(peerId) <= 1) this.gossip.sendGossipHistoryToPeer(peerId);
|
|
110
|
+
};
|
|
111
|
+
if (!DISCOVERY.ON_CONNECT_DISPATCH.DELAY) dispatchEvents();
|
|
112
|
+
else setTimeout(dispatchEvents, DISCOVERY.ON_CONNECT_DISPATCH.DELAY);
|
|
113
|
+
}
|
|
114
|
+
/** @param {string} peerId @param {'in' | 'out'} direction */
|
|
115
|
+
#onDisconnect = (peerId, direction) => {
|
|
116
|
+
if (!this.peerStore.neighborsList.length) { // If we are totally alone => kick all connecting peers and invalidate all offers
|
|
117
|
+
for (const id in this.peerStore.connecting) this.peerStore.kickPeer(id, 0, 'no_neighbors_left');
|
|
118
|
+
for (const offerId in this.offerManager.offers) this.offerManager.offers[offerId].timestamp = 0; // reset offers to retry
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const remoteIsPublic = this.cryptoCodex.isPublicNode(peerId);
|
|
122
|
+
if (this.peerStore.connected[peerId]) return; // still connected, ignore disconnection for now ?
|
|
123
|
+
if (this.verbose > ((this.publicUrl || remoteIsPublic) ? 3 : 2)) console.log(`(${this.id}) ${direction === 'in' ? 'Incoming' : 'Outgoing'} connection closed with peer ${peerId}`);
|
|
124
|
+
|
|
125
|
+
return; // no event dispatching for now
|
|
126
|
+
const dispatchEvents = () => {
|
|
127
|
+
};
|
|
128
|
+
if (!DISCOVERY.ON_DISCONNECT_DISPATCH.DELAY) dispatchEvents();
|
|
129
|
+
else setTimeout(dispatchEvents, DISCOVERY.ON_DISCONNECT_DISPATCH.DELAY);
|
|
130
|
+
}
|
|
131
|
+
#onData = (peerId, data) => {
|
|
132
|
+
const d = new Uint8Array(data);
|
|
133
|
+
if (d[0] > 127) this.gossip.handleGossipMessage(peerId, d);
|
|
134
|
+
else this.messager.handleDirectMessage(peerId, d);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// PUBLIC API
|
|
138
|
+
get publicUrl() { return this.services?.publicUrl; }
|
|
139
|
+
get publicIdentity() { return { id: this.id, publicUrl: this.publicUrl }; }
|
|
140
|
+
|
|
141
|
+
onMessageData(callback) { this.messager.on('message', callback); }
|
|
142
|
+
onGossipData(callback) { this.gossip.on('gossip', callback); }
|
|
143
|
+
|
|
144
|
+
async start() {
|
|
145
|
+
await CLOCK.sync(this.verbose);
|
|
146
|
+
this.started = true;
|
|
147
|
+
if (SIMULATION.AVOID_INTERVALS) return true; // SIMULATOR CASE
|
|
148
|
+
this.topologist.tryConnectNextBootstrap(); // first shot ASAP
|
|
149
|
+
this.arbiterInterval = setInterval(() => this.arbiter.tick(), 1000);
|
|
150
|
+
this.enhancerInterval = setInterval(() => this.topologist.tick(), DISCOVERY.LOOP_DELAY);
|
|
151
|
+
this.peerStoreInterval = setInterval(() => { this.peerStore.cleanupExpired(); this.peerStore.offerManager.tick(); }, 2500);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/** Broadcast a message to all connected peers or to a specified peer
|
|
155
|
+
* @param {string | Uint8Array | Object} data @param {string} topic @param {string} [targetId] default: broadcast to all
|
|
156
|
+
* @param {number} [timestamp] default: CLOCK.time @param {number} [HOPS] default: GOSSIP.HOPS[topic] || GOSSIP.HOPS.default */
|
|
157
|
+
broadcast(data, topic, HOPS) { this.gossip.broadcastToAll(data, topic, HOPS); }
|
|
158
|
+
/** @param {string} remoteId @param {string | Uint8Array | Object} data @param {string} type */
|
|
159
|
+
sendMessage(remoteId, data, type, spread = 1) { this.messager.sendUnicast(remoteId, data, type, spread); }
|
|
160
|
+
async tryConnectToPeer(targetId = 'toto', retry = 5) { // TO REFACTO
|
|
161
|
+
console.info('FUNCTION DISABLED FOR NOW');
|
|
162
|
+
/*if (this.peerStore.connected[targetId]) return; // already connected
|
|
163
|
+
do {
|
|
164
|
+
if (this.peerStore.offerManager.readyOffer) break;
|
|
165
|
+
else await new Promise(r => setTimeout(r, 1000)); // build in progress...
|
|
166
|
+
} while (retry-- > 0);*/
|
|
167
|
+
}
|
|
168
|
+
destroy() {
|
|
169
|
+
if (this.enhancerInterval) clearInterval(this.enhancerInterval);
|
|
170
|
+
this.enhancerInterval = null;
|
|
171
|
+
if (this.peerStoreInterval) clearInterval(this.peerStoreInterval);
|
|
172
|
+
this.peerStoreInterval = null;
|
|
173
|
+
this.peerStore.destroy();
|
|
174
|
+
if (this.wsServer) this.wsServer.close();
|
|
175
|
+
if (this.stunServer) this.stunServer.close();
|
|
176
|
+
}
|
|
177
|
+
}
|