@hive-p2p/server 1.0.18 → 1.0.20

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.
@@ -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
+ }