@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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { NetworkRenderer } from './NetworkRenderer.mjs';
|
|
2
|
+
import { CryptoCodex } from '../core/crypto-codex.mjs';
|
|
3
|
+
window.CryptoCodex = CryptoCodex; // Expose for debugging
|
|
4
|
+
|
|
5
|
+
class SimulationInterface {
|
|
6
|
+
#connectingWs = false;
|
|
7
|
+
#ws;
|
|
8
|
+
currentPeerId;
|
|
9
|
+
onSettings;
|
|
10
|
+
onPeersIds;
|
|
11
|
+
onPeerInfo;
|
|
12
|
+
onPeerMessage;
|
|
13
|
+
|
|
14
|
+
responseReceivedByType = {};
|
|
15
|
+
|
|
16
|
+
/** @param {function} onSettings @param {function} onPeersIds @param {function} onPeerInfo */
|
|
17
|
+
constructor(onSettings, onPeersIds, onPeerInfo) {
|
|
18
|
+
if (!onSettings || !onPeersIds || !onPeerInfo) return console.error('SimulationInterface requires three callback functions: onSettings, onPeersIds, onPeerInfo');
|
|
19
|
+
this.onSettings = onSettings;
|
|
20
|
+
this.onPeersIds = onPeersIds;
|
|
21
|
+
this.onPeerInfo = onPeerInfo;
|
|
22
|
+
this.#setupWs();
|
|
23
|
+
window.addEventListener('beforeunload', () => this.#ws ? this.#ws.close() : null);
|
|
24
|
+
setInterval(() => { if (this.currentPeerId) this.getPeerInfo(this.currentPeerId) }, 300);
|
|
25
|
+
setInterval(() => { this.getPeerIds() }, 5000);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#setupWs(url = 'ws://localhost:3000') {
|
|
29
|
+
if (this.#ws) this.#ws.close();
|
|
30
|
+
this.#connectingWs = true;
|
|
31
|
+
this.#ws = new WebSocket(url);
|
|
32
|
+
this.#ws.onmessage = (event) => {
|
|
33
|
+
const msg = JSON.parse(event.data);
|
|
34
|
+
|
|
35
|
+
if (msg.type === 'settings') this.onSettings(msg.data);
|
|
36
|
+
if (msg.type === 'peersIds') this.onPeersIds(msg.data);
|
|
37
|
+
if (msg.type === 'peerInfo') this.onPeerInfo(msg.data);
|
|
38
|
+
if (msg.type === 'peerMessage' && this.onPeerMessage) this.onPeerMessage(msg.remoteId, msg.data);
|
|
39
|
+
};
|
|
40
|
+
this.#connectingWs = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start(settings) { this.#sendWsMessage({ type: 'start', settings }); }
|
|
44
|
+
getPeerInfo() {
|
|
45
|
+
this.#sendWsMessage({ type: 'getPeerInfo', peerId: this.currentPeerId });
|
|
46
|
+
}
|
|
47
|
+
getPeerIds() {
|
|
48
|
+
this.#sendWsMessage({ type: 'getPeersIds' });
|
|
49
|
+
}
|
|
50
|
+
tryToConnectNode(fromId, targetId) {
|
|
51
|
+
if (!fromId || !targetId) return;
|
|
52
|
+
this.#sendWsMessage({ type: 'tryToConnectNode', fromId, targetId });
|
|
53
|
+
}
|
|
54
|
+
#sendWsMessage(msg, avoidSendingIfNotAnswered = false) {
|
|
55
|
+
if (this.#ws?.readyState === WebSocket.OPEN) {
|
|
56
|
+
if (avoidSendingIfNotAnswered && this.responseReceivedByType[msg.type] === false) return;
|
|
57
|
+
this.responseReceivedByType[msg.type] = false;
|
|
58
|
+
this.#ws.send(JSON.stringify(msg));
|
|
59
|
+
} else {
|
|
60
|
+
console.error(`WebSocket is not connected. ${this.#connectingWs ? 'Trying to connect...' : ''}`);
|
|
61
|
+
setTimeout(() => { if (!this.#connectingWs) this.#setupWs(); }, 2000);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class NetworkVisualizer {
|
|
67
|
+
autoSelectCurrentPeerCategory = ['standard', 'public']; // 'public' | 'standard' | false
|
|
68
|
+
currentPeerId;
|
|
69
|
+
lastPeerInfo;
|
|
70
|
+
networkRenderer = new NetworkRenderer();
|
|
71
|
+
simulationInterface;
|
|
72
|
+
|
|
73
|
+
peersList = {};
|
|
74
|
+
elements = {
|
|
75
|
+
peersList: document.getElementById('peersList'),
|
|
76
|
+
simulationSettings: document.getElementById('simulationSettings'),
|
|
77
|
+
|
|
78
|
+
publicPeersCount: document.getElementById('publicPeersCount'),
|
|
79
|
+
peersCount: document.getElementById('peersCount'),
|
|
80
|
+
startSimulation: document.getElementById('startSimulation'),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
constructor(isSimulation = true) {
|
|
84
|
+
if (isSimulation) {
|
|
85
|
+
this.elements.peersList.style.display = 'block';
|
|
86
|
+
this.elements.simulationSettings.style.display = 'block';
|
|
87
|
+
this.simulationInterface = new SimulationInterface(
|
|
88
|
+
(settings) => this.#handleSettings(settings), // event: onSettings
|
|
89
|
+
(peersIds) => this.#updatePeersList(peersIds), // event: onPeersIds
|
|
90
|
+
(data) => { if (data.peerId === this.currentPeerId) this.#updateNetworkFromPeerInfo(data.peerInfo); } // event: onPeerInfo
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
this.simulationInterface.onPeerMessage = (remoteId, data) => {
|
|
94
|
+
if (data.route) this.networkRenderer.displayDirectMessageRoute(remoteId, data.route);
|
|
95
|
+
else if (data.topic) this.networkRenderer.displayGossipMessageRoute(remoteId, data.senderId, data.topic, data.data);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
this.networkRenderer.onNodeLeftClick = (nodeId) => this.simulationInterface.tryToConnectNode(this.currentPeerId,nodeId);
|
|
99
|
+
this.networkRenderer.onNodeRightClick = (nodeId) => this.#setSelectedPeer(nodeId);
|
|
100
|
+
this.elements.startSimulation.onclick = () => {
|
|
101
|
+
this.mockRunning = false;
|
|
102
|
+
this.networkRenderer.clearNetwork();
|
|
103
|
+
this.simulationInterface.start(this.#getSimulatorSettings());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setInterval(() => this.networkRenderer.updateStats(), 200);
|
|
108
|
+
|
|
109
|
+
window.addEventListener('keydown', (e) => {
|
|
110
|
+
if (e.key === 'ArrowUp') this.#selectPreviousPeer();
|
|
111
|
+
if (e.key === 'ArrowDown') this.#selectNextPeer();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#setSelectedPeer(peerId) {
|
|
116
|
+
if (!peerId) return;
|
|
117
|
+
if (this.networkRenderer.currentPeerId !== peerId) {
|
|
118
|
+
console.log(`Selected peer changed, now => ${peerId}`);
|
|
119
|
+
this.networkRenderer.maxDistance = 0; // reset maxDistance to show all nodes
|
|
120
|
+
this.networkRenderer.avoidAutoZoomUntil = Date.now() + 2000; // avoid auto-zoom for 2 seconds
|
|
121
|
+
this.networkRenderer.lastAutoZoomDistance = 0;
|
|
122
|
+
this.networkRenderer.clearNetwork(); // Clear network and scroll peerId into view
|
|
123
|
+
|
|
124
|
+
for (const peerItem of document.querySelectorAll(`#peersList div[data-peer-id]`))
|
|
125
|
+
if (peerItem.dataset.peerId === peerId) peerItem.classList.add('selected');
|
|
126
|
+
else peerItem.classList.remove('selected');
|
|
127
|
+
|
|
128
|
+
const selectedItem = document.querySelector(`#peersList div[data-peer-id="${peerId}"]`);
|
|
129
|
+
if (selectedItem) {
|
|
130
|
+
const listRect = this.elements.peersList.getBoundingClientRect();
|
|
131
|
+
const itemRect = selectedItem.getBoundingClientRect();
|
|
132
|
+
if (itemRect.top < listRect.top || itemRect.bottom > listRect.bottom) {
|
|
133
|
+
this.elements.peersList.scrollTop = selectedItem.offsetTop - this.elements.peersList.offsetTop - this.elements.peersList.clientHeight / 2 + selectedItem.clientHeight / 2;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
this.networkRenderer.currentPeerId = peerId;
|
|
138
|
+
|
|
139
|
+
this.#setCurrentPeer(peerId);
|
|
140
|
+
this.simulationInterface.getPeerInfo();
|
|
141
|
+
|
|
142
|
+
}
|
|
143
|
+
#selectNextPeer() {
|
|
144
|
+
const peerIds = Object.keys(this.peersList);
|
|
145
|
+
if (peerIds.length === 0) return;
|
|
146
|
+
if (!this.currentPeerId) return this.#setSelectedPeer(peerIds[0]);
|
|
147
|
+
const currentIndex = peerIds.indexOf(this.currentPeerId);
|
|
148
|
+
const nextIndex = (currentIndex + 1) % peerIds.length;
|
|
149
|
+
this.#setSelectedPeer(peerIds[nextIndex]);
|
|
150
|
+
}
|
|
151
|
+
#selectPreviousPeer() {
|
|
152
|
+
const peerIds = Object.keys(this.peersList);
|
|
153
|
+
if (peerIds.length === 0) return;
|
|
154
|
+
if (!this.currentPeerId) return this.#setSelectedPeer(peerIds[0]);
|
|
155
|
+
const currentIndex = peerIds.indexOf(this.currentPeerId);
|
|
156
|
+
const previousIndex = (currentIndex - 1 + peerIds.length) % peerIds.length;
|
|
157
|
+
this.#setSelectedPeer(peerIds[previousIndex]);
|
|
158
|
+
}
|
|
159
|
+
#setCurrentPeer(id, clearNetworkOneChange = true) {
|
|
160
|
+
this.currentPeerId = id;
|
|
161
|
+
this.simulationInterface.currentPeerId = id;
|
|
162
|
+
this.networkRenderer.setCurrentPeer(id, clearNetworkOneChange);
|
|
163
|
+
}
|
|
164
|
+
#getSimulatorSettings() {
|
|
165
|
+
return {
|
|
166
|
+
publicPeersCount: parseInt(this.elements.publicPeersCount.value),
|
|
167
|
+
peersCount: parseInt(this.elements.peersCount.value)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// LIVE METHODS
|
|
171
|
+
#updateNetworkFromPeerInfo(peerInfo) {
|
|
172
|
+
if (!peerInfo) return;
|
|
173
|
+
this.lastPeerInfo = peerInfo;
|
|
174
|
+
|
|
175
|
+
const newlyUpdated = {};
|
|
176
|
+
const digestPeerUpdate = (id = 'toto', status = 'unknown', neighbors = []) => {
|
|
177
|
+
const isPublic = CryptoCodex.isPublicNode(id);
|
|
178
|
+
this.networkRenderer.addOrUpdateNode(id, status, isPublic, neighbors);
|
|
179
|
+
newlyUpdated[id] = true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const getNeighbors = (peerId) => {
|
|
183
|
+
const knownPeer = peerInfo.store.known[peerId];
|
|
184
|
+
return knownPeer ? Object.keys(knownPeer.neighbors || {}) : [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const knownToIgnore = {};
|
|
188
|
+
knownToIgnore[this.currentPeerId] = true;
|
|
189
|
+
for (const id of peerInfo.store.connecting) knownToIgnore[id] = true;
|
|
190
|
+
for (const id of peerInfo.store.connected) knownToIgnore[id] = true;
|
|
191
|
+
for (const id in peerInfo.store.known)
|
|
192
|
+
if (!knownToIgnore[id]) digestPeerUpdate(id, 'known', getNeighbors(id));
|
|
193
|
+
|
|
194
|
+
for (const id of peerInfo.store.connecting) digestPeerUpdate(id, 'connecting', getNeighbors(id));
|
|
195
|
+
for (const id of peerInfo.store.connected) digestPeerUpdate(id, 'connected', getNeighbors(id));
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
const nodes = this.networkRenderer.nodesStore.store;
|
|
199
|
+
const nodeIds = this.networkRenderer.nodesStore.getNodesIds();
|
|
200
|
+
for (const id of nodeIds) // filter absent nodes
|
|
201
|
+
if (!newlyUpdated[id] && id !== this.currentPeerId) this.networkRenderer.removeNode(id);
|
|
202
|
+
|
|
203
|
+
// ensure current peer is updated
|
|
204
|
+
if (peerInfo.id === this.currentPeerId) digestPeerUpdate(peerInfo.id, 'current', getNeighbors(peerInfo.id));
|
|
205
|
+
|
|
206
|
+
// Create connections
|
|
207
|
+
const connections = [];
|
|
208
|
+
for (const id in nodes)
|
|
209
|
+
for (const neighborId of nodes[id].neighbors) connections.push([id, neighborId]);
|
|
210
|
+
|
|
211
|
+
//console.log(`Updated network map: ${Object.keys(nodes).length} nodes | ${Object.keys(connections).length} connections`);
|
|
212
|
+
this.networkRenderer.digestConnectionsArray(connections);
|
|
213
|
+
}
|
|
214
|
+
// SIMULATION METHODS
|
|
215
|
+
#handleSettings(settings = { publicPeersCount: 2, peersCount: 5 }) {
|
|
216
|
+
if (settings.publicPeersCount) this.elements.publicPeersCount.value = settings.publicPeersCount;
|
|
217
|
+
if (settings.peersCount) this.elements.peersCount.value = settings.peersCount;
|
|
218
|
+
if (settings.autoStart) this.simulationInterface.getPeerIds();
|
|
219
|
+
}
|
|
220
|
+
#createPeerItem(peerId) {
|
|
221
|
+
const peerItem = document.createElement('div');
|
|
222
|
+
peerItem.dataset.peerId = peerId;
|
|
223
|
+
peerItem.textContent = peerId;
|
|
224
|
+
peerItem.onclick = () => this.#setSelectedPeer(peerId);
|
|
225
|
+
return peerItem;
|
|
226
|
+
}
|
|
227
|
+
#updatePeersList(peersData, element = this.elements.peersList) {
|
|
228
|
+
//element.innerHTML = '<h3>Peers list</h3>';
|
|
229
|
+
|
|
230
|
+
const peerIds = {};
|
|
231
|
+
for (const category in peersData)
|
|
232
|
+
for (const peerId of peersData[category]) {
|
|
233
|
+
peerIds[peerId] = true;
|
|
234
|
+
if (this.peersList[peerId]) continue; // already listed
|
|
235
|
+
const peerItem = this.#createPeerItem(peerId);
|
|
236
|
+
element.appendChild(peerItem);
|
|
237
|
+
this.peersList[peerId] = peerItem;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const peerId of Object.keys(this.peersList)) // remove absent peers
|
|
241
|
+
if (!peerIds[peerId]) { this.peersList[peerId].remove(); delete this.peersList[peerId]; }
|
|
242
|
+
|
|
243
|
+
if (this.currentPeerId) return this.#setSelectedPeer(this.currentPeerId); // Auto-select current peer
|
|
244
|
+
|
|
245
|
+
for (const category of this.autoSelectCurrentPeerCategory)
|
|
246
|
+
for (const peerId of peersData[category] || []) return this.#setSelectedPeer(peerId);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const networkVisualizer = new NetworkVisualizer(true);
|
|
251
|
+
if (typeof window !== 'undefined') {
|
|
252
|
+
window.networkVisualizer = networkVisualizer; // Expose for debugging
|
|
253
|
+
window.networkRenderer = networkVisualizer.networkRenderer; // Expose for debugging
|
|
254
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronized Network Clock
|
|
3
|
+
* Simple, efficient NTP-based time synchronization
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class Clock {
|
|
7
|
+
verbose;
|
|
8
|
+
mockMode; // if true, use local time without sync
|
|
9
|
+
static #instance = null;
|
|
10
|
+
|
|
11
|
+
#offset = null; // ms difference from local time
|
|
12
|
+
#syncing = false;
|
|
13
|
+
#lastSync = 0;
|
|
14
|
+
#sources = ['time.google.com', 'time.cloudflare.com', 'pool.ntp.org'];
|
|
15
|
+
#browserSources = [
|
|
16
|
+
'worldtimeapi.org/api/timezone/UTC',
|
|
17
|
+
'timeapi.io/api/Time/current/zone?timeZone=UTC',
|
|
18
|
+
'api.github.com'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
constructor(verbose = 0, mockMode = false) {
|
|
22
|
+
this.verbose = verbose;
|
|
23
|
+
this.mockMode = mockMode;
|
|
24
|
+
if (Clock.#instance) return Clock.#instance;
|
|
25
|
+
else Clock.#instance = this;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// PUBLIC API
|
|
29
|
+
static get instance() { return Clock.#instance || new Clock(); }
|
|
30
|
+
static get time() { return Clock.instance.time; }
|
|
31
|
+
get time() {
|
|
32
|
+
if (this.mockMode) return Date.now();
|
|
33
|
+
if (this.#offset === null) return null;
|
|
34
|
+
return Date.now() + Math.round(this.#offset);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async sync(verbose) {
|
|
38
|
+
if (verbose !== undefined) this.verbose = verbose;
|
|
39
|
+
if (this.mockMode) return Date.now();
|
|
40
|
+
|
|
41
|
+
if (this.#syncing) {
|
|
42
|
+
while (this.#syncing) await new Promise(resolve => setTimeout(resolve, 50));
|
|
43
|
+
return this.time;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.#syncing = true;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const samples = await this.#fetchTimeSamples();
|
|
50
|
+
if (samples.length === 0) {
|
|
51
|
+
console.warn('[Clock] All NTP sources failed, using local time');
|
|
52
|
+
this.#offset = 0;
|
|
53
|
+
return this.time;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.#offset = this.#calculateOffset(samples);
|
|
57
|
+
this.#lastSync = Date.now();
|
|
58
|
+
|
|
59
|
+
if (samples.length < this.#sources.length)
|
|
60
|
+
setTimeout(() => this.#backgroundRefine(), 100);
|
|
61
|
+
return this.time;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('[Clock] Sync failed:', error);
|
|
64
|
+
this.#offset = 0;
|
|
65
|
+
return this.time;
|
|
66
|
+
} finally {
|
|
67
|
+
this.#syncing = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get status() {
|
|
72
|
+
if (this.mockMode) return {
|
|
73
|
+
synchronized: true, syncing: false, offset: 0,
|
|
74
|
+
lastSync: Date.now(), age: 0
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
synchronized: this.#offset !== null,
|
|
78
|
+
syncing: this.#syncing,
|
|
79
|
+
offset: this.#offset,
|
|
80
|
+
lastSync: this.#lastSync,
|
|
81
|
+
age: this.#lastSync ? Date.now() - this.#lastSync : null
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// PRIVATE METHODS
|
|
86
|
+
async #fetchTimeSamples() {
|
|
87
|
+
const sources = (typeof window !== 'undefined') ? this.#browserSources : this.#sources;
|
|
88
|
+
const promises = sources.map(source => this.#fetchTimeFromSource(source));
|
|
89
|
+
const results = await Promise.allSettled(promises);
|
|
90
|
+
|
|
91
|
+
const samples = [];
|
|
92
|
+
for (const result of results) {
|
|
93
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
94
|
+
samples.push(result.value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return samples;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async #fetchTimeFromSource(source) {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000); // Plus de timeout
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const startTime = Date.now();
|
|
106
|
+
const url = source.startsWith('http') ? source : `https://${source}`;
|
|
107
|
+
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
method: source.includes('api.github.com') ? 'HEAD' : 'GET',
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
cache: 'no-cache',
|
|
112
|
+
mode: 'cors' // Explicit CORS
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const networkLatency = (Date.now() - startTime) / 2;
|
|
116
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
117
|
+
|
|
118
|
+
let serverTime;
|
|
119
|
+
|
|
120
|
+
if (source.includes('worldtimeapi')) {
|
|
121
|
+
const data = await response.json();
|
|
122
|
+
serverTime = new Date(data.utc_datetime).getTime();
|
|
123
|
+
} else if (source.includes('timeapi')) {
|
|
124
|
+
const data = await response.json();
|
|
125
|
+
serverTime = new Date(data.dateTime).getTime();
|
|
126
|
+
} else {
|
|
127
|
+
const dateHeader = response.headers.get('date');
|
|
128
|
+
if (!dateHeader) throw new Error('No date header');
|
|
129
|
+
serverTime = new Date(dateHeader).getTime();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isNaN(serverTime)) throw new Error('Invalid time data');
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
source,
|
|
136
|
+
serverTime: serverTime + networkLatency,
|
|
137
|
+
localTime: Date.now(),
|
|
138
|
+
latency: networkLatency * 2
|
|
139
|
+
};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (this.verbose) console.warn(`[Clock] Failed to fetch time from ${source}:`, error.message);
|
|
142
|
+
return null; // Retourne explicitement null au lieu d'undefined
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timeoutId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#calculateOffset(samples) {
|
|
149
|
+
// Filter out null samples (au cas où)
|
|
150
|
+
const validSamples = samples.filter(sample => sample !== null);
|
|
151
|
+
if (validSamples.length === 0) return 0;
|
|
152
|
+
if (validSamples.length === 1) return validSamples[0].serverTime - validSamples[0].localTime;
|
|
153
|
+
|
|
154
|
+
validSamples.sort((a, b) => a.latency - b.latency);
|
|
155
|
+
|
|
156
|
+
const offsets = [];
|
|
157
|
+
for (const sample of validSamples)
|
|
158
|
+
offsets.push(sample.serverTime - sample.localTime);
|
|
159
|
+
|
|
160
|
+
offsets.sort((a, b) => a - b);
|
|
161
|
+
const mid = Math.floor(offsets.length / 2);
|
|
162
|
+
|
|
163
|
+
if (offsets.length % 2 === 0)
|
|
164
|
+
return (offsets[mid - 1] + offsets[mid]) / 2;
|
|
165
|
+
return offsets[mid];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async #backgroundRefine() {
|
|
169
|
+
if (this.#syncing) return;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const samples = await this.#fetchTimeSamples();
|
|
173
|
+
if (samples.length === 0) return;
|
|
174
|
+
|
|
175
|
+
const newOffset = this.#calculateOffset(samples);
|
|
176
|
+
if (Math.abs(newOffset - this.#offset) > 100)
|
|
177
|
+
this.#offset = newOffset;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (this.verbose) console.warn('[Clock] Background refine failed:', error);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function CLOCK_TEST() {
|
|
185
|
+
const startTime = Date.now();
|
|
186
|
+
const clock = new Clock(1); // Verbose pour debug
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await clock.sync();
|
|
190
|
+
console.log('Synchronized in:', Date.now() - startTime, 'ms');
|
|
191
|
+
console.log('Synchronized time:', new Date(clock.time).toISOString());
|
|
192
|
+
console.log('Clock status:', clock.status);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error('Sync failed:', error);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronized Network Clock
|
|
3
|
+
* Simple, efficient NTP-based time synchronization
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class Clock {
|
|
7
|
+
verbose;
|
|
8
|
+
mockMode; // if true, use local time without sync
|
|
9
|
+
static #instance = null;
|
|
10
|
+
|
|
11
|
+
#offset = null; // ms difference from local time
|
|
12
|
+
#syncing = false;
|
|
13
|
+
#lastSync = 0;
|
|
14
|
+
#sources = ['time.google.com', 'time.cloudflare.com', 'pool.ntp.org'];
|
|
15
|
+
|
|
16
|
+
constructor(verbose = 0, mockMode = false) {
|
|
17
|
+
this.verbose = verbose;
|
|
18
|
+
this.mockMode = mockMode;
|
|
19
|
+
if (Clock.#instance) return Clock.#instance;
|
|
20
|
+
else Clock.#instance = this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// PUBLIC API
|
|
24
|
+
static get instance() { return Clock.#instance || new Clock(); }
|
|
25
|
+
static get time() { return Clock.instance.time; } // Sync API - returns current synchronized time or null
|
|
26
|
+
get time() {
|
|
27
|
+
if (this.mockMode) return Date.now();
|
|
28
|
+
if (this.#offset === null) return null;
|
|
29
|
+
return Date.now() + Math.round(this.#offset);
|
|
30
|
+
}
|
|
31
|
+
async sync(verbose) { // Force synchronization - returns promise with synchronized time
|
|
32
|
+
if (verbose !== undefined) this.verbose = verbose;
|
|
33
|
+
if (this.mockMode) return Date.now(); // Bypass sync in mock mode
|
|
34
|
+
|
|
35
|
+
if (this.#syncing) { // Wait for current sync to complete
|
|
36
|
+
while (this.#syncing) await new Promise(resolve => setTimeout(resolve, 50));
|
|
37
|
+
return this.time;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.#syncing = true;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const samples = await this.#fetchTimeSamples();
|
|
44
|
+
if (samples.length === 0) {
|
|
45
|
+
console.warn('[Clock] All NTP sources failed, using local time');
|
|
46
|
+
this.#offset = 0;
|
|
47
|
+
return this.time;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.#offset = this.#calculateOffset(samples);
|
|
51
|
+
this.#lastSync = Date.now();
|
|
52
|
+
|
|
53
|
+
// Continue refining in background if we got partial results
|
|
54
|
+
if (samples.length < this.#sources.length) setTimeout(() => this.#backgroundRefine(), 100);
|
|
55
|
+
return this.time;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('[Clock] Sync failed:', error);
|
|
58
|
+
this.#offset = 0; // Fallback to local time
|
|
59
|
+
return this.time;
|
|
60
|
+
} finally { this.#syncing = false; }
|
|
61
|
+
}
|
|
62
|
+
get status() { // Get sync status info
|
|
63
|
+
if (this.mockMode) return { synchronized: true, syncing: false, offset: 0, lastSync: Date.now(), age: 0 };
|
|
64
|
+
return {
|
|
65
|
+
synchronized: this.#offset !== null,
|
|
66
|
+
syncing: this.#syncing,
|
|
67
|
+
offset: this.#offset,
|
|
68
|
+
lastSync: this.#lastSync,
|
|
69
|
+
age: this.#lastSync ? Date.now() - this.#lastSync : null
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// PRIVATE METHODS
|
|
74
|
+
async #fetchTimeSamples() { // Fetch time samples from all sources in parallel
|
|
75
|
+
const promises = this.#sources.map(source => this.#fetchTimeFromSource(source));
|
|
76
|
+
const results = await Promise.allSettled(promises);
|
|
77
|
+
const samples = [];
|
|
78
|
+
for (const result of results) if (result.status === 'fulfilled') samples.push(result.value);
|
|
79
|
+
return samples;
|
|
80
|
+
}
|
|
81
|
+
/** @param {'time.google.com' | 'time.cloudflare.com' | 'pool.ntp.org'} source */
|
|
82
|
+
async #fetchTimeFromSource(source) { // Fetch time from a single NTP source
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const startTime = Date.now();
|
|
88
|
+
const response = await fetch(`https://${source}`, { method: 'HEAD', signal: controller.signal, cache: 'no-cache' });
|
|
89
|
+
const networkLatency = (Date.now() - startTime) / 2; // Rough RTT/2
|
|
90
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
91
|
+
|
|
92
|
+
const serverTime = new Date(response.headers.get('date')).getTime();
|
|
93
|
+
if (isNaN(serverTime)) throw new Error('Invalid date header');
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
source,
|
|
97
|
+
serverTime: serverTime + networkLatency, // Compensate for network delay
|
|
98
|
+
localTime: Date.now(),
|
|
99
|
+
latency: networkLatency * 2
|
|
100
|
+
};
|
|
101
|
+
} finally { clearTimeout(timeoutId); }
|
|
102
|
+
}
|
|
103
|
+
/** @param {Array<{serverTime: number, localTime: number, latency: number}>} samples */
|
|
104
|
+
#calculateOffset(samples) { // Calculate offset from multiple samples
|
|
105
|
+
if (samples.length === 1) return samples[0].serverTime - samples[0].localTime;
|
|
106
|
+
samples.sort((a, b) => a.latency - b.latency); // Sort by latency, prefer lower latency sources
|
|
107
|
+
|
|
108
|
+
const offsets = [];
|
|
109
|
+
for (const sample of samples) offsets.push(sample.serverTime - sample.localTime);
|
|
110
|
+
offsets.sort((a, b) => a - b); // Use median to filter outliers
|
|
111
|
+
const mid = Math.floor(offsets.length / 2);
|
|
112
|
+
|
|
113
|
+
if (offsets.length % 2 === 0) return (offsets[mid - 1] + offsets[mid]) / 2;
|
|
114
|
+
return offsets[mid];
|
|
115
|
+
}
|
|
116
|
+
async #backgroundRefine() { // Background refinement after initial sync
|
|
117
|
+
if (this.#syncing) return;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const samples = await this.#fetchTimeSamples();
|
|
121
|
+
if (samples.length === 0) return; // All failed
|
|
122
|
+
|
|
123
|
+
// Only update if change is significant (> 100ms)
|
|
124
|
+
const newOffset = this.#calculateOffset(samples);
|
|
125
|
+
if (Math.abs(newOffset - this.#offset) > 100) this.#offset = newOffset;
|
|
126
|
+
} catch (error) { if (this.verbose) console.warn('[Clock] Background refine failed:', error); }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
export async function CLOCK_TEST() { // DEBUG TEST WHILE RUNNING AS STANDALONE
|
|
132
|
+
const startTime = Date.now();
|
|
133
|
+
const clock = new Clock();
|
|
134
|
+
clock.sync().then(() => {
|
|
135
|
+
console.log('Synchronized in: ', Date.now() - startTime, 'ms');
|
|
136
|
+
console.log('Synchronized time:', new Date(clock.time).toISOString());
|
|
137
|
+
console.log('Clock status:', clock.status);
|
|
138
|
+
}).catch(console.error);
|
|
139
|
+
|
|
140
|
+
while (true) {
|
|
141
|
+
console.log('Clock status:', clock.status);
|
|
142
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export class Converter {
|
|
2
|
+
IS_BROWSER = typeof window !== 'undefined';
|
|
3
|
+
FROMBASE64_AVAILABLE = typeof Uint8Array.fromBase64 === 'function';
|
|
4
|
+
textEncoder = new TextEncoder();
|
|
5
|
+
textDecoder = new TextDecoder();
|
|
6
|
+
buffer2 = new ArrayBuffer(2); view2 = new DataView(this.buffer2);
|
|
7
|
+
buffer4 = new ArrayBuffer(4); view4 = new DataView(this.buffer4);
|
|
8
|
+
buffer8 = new ArrayBuffer(8); view8 = new DataView(this.buffer8);
|
|
9
|
+
hexMap = { '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, 'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15, 'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15 };
|
|
10
|
+
|
|
11
|
+
// ... TO BYTES
|
|
12
|
+
/** Number should be between 0 and 65535 @param {number} num - Integer to convert to 2 bytes Uint8Array */
|
|
13
|
+
numberTo2Bytes(num) { this.view2.setUint16(0, num, true); return new Uint8Array(this.buffer2); }
|
|
14
|
+
/** Number should be between 0 and 4294967295 @param {number} num - Integer to convert to 4 bytes Uint8Array */
|
|
15
|
+
numberTo4Bytes(num) { this.view4.setUint32(0, num, true); return new Uint8Array(this.buffer4); }
|
|
16
|
+
/** Number should be between 0 and 18446744073709551615 @param {number} num - Integer to convert to 8 bytes Uint8Array */
|
|
17
|
+
numberTo8Bytes(num) { this.view8.setBigUint64(0, BigInt(num), true); return new Uint8Array(this.buffer8); }
|
|
18
|
+
stringToBytes(str = 'toto') { return this.textEncoder.encode(str); }
|
|
19
|
+
/** @param {string} hex - Hex string to convert to Uint8Array */
|
|
20
|
+
hexToBytes(hex) {
|
|
21
|
+
const length = hex.length / 2;
|
|
22
|
+
const uint8Array = new Uint8Array(length);
|
|
23
|
+
for (let i = 0, j = 0; i < length; ++i, j += 2) uint8Array[i] = (this.hexMap[hex[j]] << 4) + this.hexMap[hex[j + 1]];
|
|
24
|
+
return uint8Array;
|
|
25
|
+
}
|
|
26
|
+
/** Base64 string to convert to Uint8Array @param {string} base64 @returns {Uint8Array} */
|
|
27
|
+
base64toBytes(base64) {
|
|
28
|
+
if (!this.IS_BROWSER) return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
29
|
+
if (this.FROMBASE64_AVAILABLE) return Uint8Array.fromBase64(base64);
|
|
30
|
+
const binaryString = atob(base64);
|
|
31
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
32
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
// BYTES TO ...
|
|
36
|
+
/** @param {Uint8Array} uint8Array - Uint8Array to convert to number */
|
|
37
|
+
bytes2ToNumber(uint8Array) { for (let i = 0; i < 2; i++) this.view2.setUint8(i, uint8Array[i]); return this.view2.getUint16(0, true); }
|
|
38
|
+
/** @param {Uint8Array} uint8Array - Uint8Array to convert to number */
|
|
39
|
+
bytes4ToNumber(uint8Array) { for (let i = 0; i < 4; i++) this.view4.setUint8(i, uint8Array[i]); return this.view4.getUint32(0, true); }
|
|
40
|
+
/** @param {Uint8Array} uint8Array - Uint8Array to convert to number */
|
|
41
|
+
bytes8ToNumber(uint8Array) { for (let i = 0; i < 8; i++) this.view8.setUint8(i, uint8Array[i]); return Number(this.view8.getBigUint64(0, true)); }
|
|
42
|
+
/** @param {Uint8Array} uint8Array - Uint8Array to convert to string */
|
|
43
|
+
bytesToString(uint8Array) { return this.textDecoder.decode(uint8Array); }
|
|
44
|
+
/** @param {Uint8Array} uint8Array - Uint8Array to convert to string */
|
|
45
|
+
static bytesToHex(uint8Array, minLength = 0) {
|
|
46
|
+
let hexStr = '';
|
|
47
|
+
for (const byte of uint8Array) hexStr += byte < 16 ? `0${byte.toString(16)}` : byte.toString(16);
|
|
48
|
+
if (minLength > 0) { hexStr = hexStr.padStart(minLength, '0'); }
|
|
49
|
+
return hexStr;
|
|
50
|
+
}
|
|
51
|
+
/** @param {Uint8Array} uint8Array - Uint8Array to convert to string */
|
|
52
|
+
bytesToHex(uint8Array, minLength = 0) { return Converter.bytesToHex(uint8Array, minLength); }
|
|
53
|
+
// OTHERS
|
|
54
|
+
/** @param {string} hex - Hex string to convert to bits @param {'string' | 'arrayOfString' | 'arrayOfNumbers'} format - Output format, default: string */
|
|
55
|
+
static hexToBits(hex = 'ffffff', format = 'string') {
|
|
56
|
+
let bitsString = []; // WE USE STRING METHODS FOR PERFORMANCE (number '0' = 8 Bytes | string '0' = 1 Byte)
|
|
57
|
+
if (format === 'arrayOfNumbers') for (let i = 0; i < hex.length; i++) bitsString.push(...(parseInt(hex[i], 16).toString(2).padStart(4, '0').split('').map(b => parseInt(b, 10))));
|
|
58
|
+
else for (let i = 0; i < hex.length; i++) bitsString.push(parseInt(hex[i], 16).toString(2).padStart(4, '0'));
|
|
59
|
+
return format === 'string' ? bitsString.join('') : bitsString;
|
|
60
|
+
}
|
|
61
|
+
/** @param {Uint8Array} uint8Array - Uint8Array to convert to bits @param {'string' | 'arrayOfString' | 'arrayOfNumbers'} format - Output format, default: string */
|
|
62
|
+
static bytesToBits(uint8Array, format = 'string') { return this.hexToBits(this.bytesToHex(uint8Array), format); }
|
|
63
|
+
static ipToInt(ip = '192.168.0.1') { return ip.split('.').reduce((int, oct) => (int << 8) + parseInt(oct, 10), 0); }
|
|
64
|
+
}
|