mirror of
https://github.com/lklynet/hypermind.git
synced 2026-05-03 09:30:36 +00:00
642 lines
19 KiB
JavaScript
642 lines
19 KiB
JavaScript
const express = require("express");
|
|
const Hyperswarm = require("hyperswarm");
|
|
const crypto = require("crypto");
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// --- CONFIGURATION ---
|
|
const TOPIC_NAME = "hypermind-lklynet-v1";
|
|
const TOPIC = crypto.createHash("sha256").update(TOPIC_NAME).digest();
|
|
|
|
// Gossip protocol tuning
|
|
const GOSSIP_FANOUT = 10; // Relay to max 10 random peers instead of all
|
|
const HEARTBEAT_INTERVAL_FAST = 1000; // 1 second during startup
|
|
const HEARTBEAT_INTERVAL_SLOW = 15000; // 15 seconds at steady state
|
|
const STARTUP_DURATION = 120000; // Stay in fast mode for 2 minutes
|
|
const PEER_STALE_TIMEOUT = 90000; // 90 seconds before peer considered stale
|
|
|
|
// --- BLOOM FILTER FOR MESSAGE DEDUPLICATION ---
|
|
// Simple bloom filter to prevent re-relaying messages we've already seen
|
|
class BloomFilter {
|
|
constructor(size = 10000, hashCount = 3) {
|
|
this.size = size;
|
|
this.hashCount = hashCount;
|
|
this.bits = new Uint8Array(Math.ceil(size / 8));
|
|
}
|
|
|
|
_hash(str, seed) {
|
|
let h = seed;
|
|
for (let i = 0; i < str.length; i++) {
|
|
h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
|
}
|
|
return h % this.size;
|
|
}
|
|
|
|
add(item) {
|
|
for (let i = 0; i < this.hashCount; i++) {
|
|
const idx = this._hash(item, i * 0x9e3779b9);
|
|
this.bits[idx >>> 3] |= (1 << (idx & 7));
|
|
}
|
|
}
|
|
|
|
has(item) {
|
|
for (let i = 0; i < this.hashCount; i++) {
|
|
const idx = this._hash(item, i * 0x9e3779b9);
|
|
if ((this.bits[idx >>> 3] & (1 << (idx & 7))) === 0) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
clear() {
|
|
this.bits.fill(0);
|
|
}
|
|
}
|
|
|
|
// Time-bucketed bloom filter - rotates every 30 seconds
|
|
let currentBloom = new BloomFilter();
|
|
let previousBloom = new BloomFilter();
|
|
|
|
function rotateBloomFilters() {
|
|
previousBloom = currentBloom;
|
|
currentBloom = new BloomFilter();
|
|
}
|
|
|
|
// Check if we've recently relayed this message
|
|
function hasRelayedMessage(id, seq) {
|
|
const key = `${id}:${seq}`;
|
|
return currentBloom.has(key) || previousBloom.has(key);
|
|
}
|
|
|
|
// Mark message as relayed
|
|
function markRelayed(id, seq) {
|
|
const key = `${id}:${seq}`;
|
|
currentBloom.add(key);
|
|
}
|
|
|
|
// Rotate bloom filters periodically
|
|
setInterval(rotateBloomFilters, 30000);
|
|
|
|
// --- HYPERLOGLOG FOR PEER COUNTING ---
|
|
// Approximate unique peer count with fixed ~1.5KB memory
|
|
// Accuracy: ~2% error rate, can count millions of peers
|
|
class HyperLogLog {
|
|
constructor(precision = 10) {
|
|
// 2^precision registers, precision=10 gives 1024 registers (~1KB)
|
|
this.precision = precision;
|
|
this.registerCount = 1 << precision;
|
|
this.registers = new Uint8Array(this.registerCount);
|
|
this.alphaMM = this._getAlpha() * this.registerCount * this.registerCount;
|
|
}
|
|
|
|
_getAlpha() {
|
|
// Bias correction constant
|
|
switch (this.precision) {
|
|
case 4: return 0.673;
|
|
case 5: return 0.697;
|
|
case 6: return 0.709;
|
|
default: return 0.7213 / (1 + 1.079 / this.registerCount);
|
|
}
|
|
}
|
|
|
|
_hash(str) {
|
|
// Simple 32-bit hash (good enough for HLL)
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < str.length; i++) {
|
|
h ^= str.charCodeAt(i);
|
|
h = (h * 0x01000193) >>> 0;
|
|
}
|
|
return h;
|
|
}
|
|
|
|
_countLeadingZeros(value, maxBits) {
|
|
if (value === 0) return maxBits;
|
|
let count = 0;
|
|
while ((value & (1 << (maxBits - 1 - count))) === 0 && count < maxBits) {
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
add(item) {
|
|
const hash = this._hash(item);
|
|
// Use first 'precision' bits for register index
|
|
const registerIndex = hash >>> (32 - this.precision);
|
|
// Use remaining bits to count leading zeros
|
|
const remainingBits = hash << this.precision;
|
|
const leadingZeros = this._countLeadingZeros(remainingBits, 32 - this.precision) + 1;
|
|
|
|
// Store maximum leading zeros seen for this register
|
|
if (leadingZeros > this.registers[registerIndex]) {
|
|
this.registers[registerIndex] = leadingZeros;
|
|
}
|
|
}
|
|
|
|
count() {
|
|
// Harmonic mean of 2^register values
|
|
let harmonicSum = 0;
|
|
let zeroRegisters = 0;
|
|
|
|
for (let i = 0; i < this.registerCount; i++) {
|
|
harmonicSum += Math.pow(2, -this.registers[i]);
|
|
if (this.registers[i] === 0) zeroRegisters++;
|
|
}
|
|
|
|
let estimate = this.alphaMM / harmonicSum;
|
|
|
|
// Small range correction (linear counting)
|
|
if (estimate <= 2.5 * this.registerCount && zeroRegisters > 0) {
|
|
estimate = this.registerCount * Math.log(this.registerCount / zeroRegisters);
|
|
}
|
|
|
|
return Math.round(estimate);
|
|
}
|
|
}
|
|
|
|
// Global peer counter - tracks all unique peers ever seen
|
|
const peerCounter = new HyperLogLog(10); // ~1KB, 2% error
|
|
|
|
// --- SECURITY ---
|
|
// We use Ed25519 for signatures and a PoW puzzle to prevent Sybil attacks.
|
|
// Difficulty: Hash(ID + nonce) must start with '0000'
|
|
const POW_PREFIX = "0000";
|
|
|
|
console.log("[Security] Generating Identity & Solving PoW...");
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
const MY_ID = publicKey.export({ type: "spki", format: "der" }).toString("hex");
|
|
let MY_NONCE = 0;
|
|
while (true) {
|
|
const hash = crypto
|
|
.createHash("sha256")
|
|
.update(MY_ID + MY_NONCE)
|
|
.digest("hex");
|
|
if (hash.startsWith(POW_PREFIX)) break;
|
|
MY_NONCE++;
|
|
}
|
|
console.log(
|
|
`[Security] Identity ready. ID: ${MY_ID.slice(0, 8)}... Nonce: ${MY_NONCE}`
|
|
);
|
|
|
|
let mySeq = 0;
|
|
|
|
const seenPeers = new Map();
|
|
|
|
const sseClients = new Set();
|
|
|
|
seenPeers.set(MY_ID, { seq: mySeq, lastSeen: Date.now() });
|
|
peerCounter.add(MY_ID); // Count ourselves
|
|
|
|
// Throttle updates to once per second
|
|
let lastBroadcast = 0;
|
|
function broadcastUpdate() {
|
|
const now = Date.now();
|
|
if (now - lastBroadcast < 1000) return;
|
|
lastBroadcast = now;
|
|
|
|
const data = JSON.stringify({
|
|
count: peerCounter.count(), // Use HyperLogLog for total peer count
|
|
direct: swarm.connections.size,
|
|
id: MY_ID,
|
|
});
|
|
|
|
for (const client of sseClients) {
|
|
client.write(`data: ${data}\n\n`);
|
|
}
|
|
}
|
|
|
|
const swarm = new Hyperswarm();
|
|
|
|
swarm.on("connection", (socket) => {
|
|
// Start adaptive heartbeat on first connection
|
|
startHeartbeatIfNeeded();
|
|
|
|
const sig = crypto
|
|
.sign(null, Buffer.from(`seq:${mySeq}`), privateKey)
|
|
.toString("hex");
|
|
const hello = JSON.stringify({
|
|
type: "HEARTBEAT",
|
|
id: MY_ID,
|
|
seq: mySeq,
|
|
hops: 0,
|
|
nonce: MY_NONCE,
|
|
sig,
|
|
});
|
|
socket.write(hello);
|
|
broadcastUpdate();
|
|
|
|
socket.on("data", (data) => {
|
|
try {
|
|
const msgs = data
|
|
.toString()
|
|
.split("\n")
|
|
.filter((x) => x.trim());
|
|
for (const msgStr of msgs) {
|
|
const msg = JSON.parse(msgStr);
|
|
handleMessage(msg, socket);
|
|
}
|
|
} catch (e) {
|
|
// console.error('Invalid message', e);
|
|
}
|
|
});
|
|
|
|
socket.on("close", () => {
|
|
if (socket.peerId && seenPeers.has(socket.peerId)) {
|
|
seenPeers.delete(socket.peerId);
|
|
}
|
|
broadcastUpdate();
|
|
});
|
|
|
|
socket.on("error", () => {});
|
|
});
|
|
|
|
const discovery = swarm.join(TOPIC);
|
|
discovery.flushed().then(() => {
|
|
console.log("[P2P] Joined topic:", TOPIC_NAME);
|
|
});
|
|
|
|
function handleMessage(msg, sourceSocket) {
|
|
if (msg.type === "HEARTBEAT") {
|
|
const { id, seq, hops, nonce, sig } = msg;
|
|
|
|
// 1. Verify PoW
|
|
if (!nonce) return;
|
|
const powHash = crypto
|
|
.createHash("sha256")
|
|
.update(id + nonce)
|
|
.digest("hex");
|
|
if (!powHash.startsWith(POW_PREFIX)) return; // Invalid PoW
|
|
|
|
// 2. Check Sequence (Optimization: Drop duplicates before expensive verify)
|
|
const stored = seenPeers.get(id);
|
|
if (stored && seq <= stored.seq) return; // Ignore old/duplicate messages
|
|
|
|
// 3. Verify Signature
|
|
if (!sig) return;
|
|
try {
|
|
let key;
|
|
if (stored && stored.key) {
|
|
key = stored.key;
|
|
} else {
|
|
key = crypto.createPublicKey({
|
|
key: Buffer.from(id, "hex"),
|
|
format: "der",
|
|
type: "spki",
|
|
});
|
|
}
|
|
|
|
const verified = crypto.verify(
|
|
null,
|
|
Buffer.from(`seq:${seq}`),
|
|
key,
|
|
Buffer.from(sig, "hex")
|
|
);
|
|
if (!verified) return; // Invalid Signature
|
|
|
|
// Track unique peer in HyperLogLog counter (always, even if over MAX_PEERS)
|
|
const prevCount = peerCounter.count();
|
|
peerCounter.add(id);
|
|
const countChanged = peerCounter.count() !== prevCount;
|
|
|
|
// Update Peer
|
|
if (hops === 0) {
|
|
sourceSocket.peerId = id;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const wasNew = !stored;
|
|
|
|
// Store in seenPeers only if we have room (memory limit)
|
|
// But we still count and relay even if we can't store
|
|
const canStore = stored || seenPeers.size < MAX_PEERS;
|
|
if (canStore) {
|
|
seenPeers.set(id, { seq, lastSeen: now, keyDer });
|
|
}
|
|
|
|
if ((wasNew && canStore) || countChanged) broadcastUpdate();
|
|
|
|
// Only relay if we haven't already relayed this message (bloom filter check)
|
|
if (hops < 3 && !hasRelayedMessage(id, seq)) {
|
|
markRelayed(id, seq);
|
|
relayMessage({ ...msg, hops: hops + 1 }, sourceSocket);
|
|
}
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
} else if (msg.type === "LEAVE") {
|
|
const { id, hops } = msg;
|
|
if (seenPeers.has(id)) {
|
|
seenPeers.delete(id);
|
|
broadcastUpdate();
|
|
|
|
// Use id:leave as key for LEAVE messages
|
|
if (hops < 3 && !hasRelayedMessage(id, "leave")) {
|
|
markRelayed(id, "leave");
|
|
relayMessage({ ...msg, hops: hops + 1 }, sourceSocket);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fisher-Yates shuffle for random peer selection
|
|
function shuffleArray(array) {
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
return array;
|
|
}
|
|
|
|
function relayMessage(msg, sourceSocket) {
|
|
const data = JSON.stringify(msg) + "\n";
|
|
|
|
// Get all eligible sockets (excluding source)
|
|
const eligibleSockets = [...swarm.connections].filter(s => s !== sourceSocket);
|
|
|
|
// Apply fanout limiting - only relay to GOSSIP_FANOUT random peers
|
|
const targetSockets = eligibleSockets.length <= GOSSIP_FANOUT
|
|
? eligibleSockets
|
|
: shuffleArray(eligibleSockets).slice(0, GOSSIP_FANOUT);
|
|
|
|
for (const socket of targetSockets) {
|
|
socket.write(data);
|
|
}
|
|
}
|
|
|
|
// Adaptive Heartbeat - fast at startup, slows down after STARTUP_DURATION
|
|
// Timer starts when first connection is established, not at process start
|
|
let heartbeatStartTime = null;
|
|
let heartbeatStarted = false;
|
|
|
|
function getHeartbeatInterval() {
|
|
if (!heartbeatStartTime) return HEARTBEAT_INTERVAL_FAST;
|
|
const elapsed = Date.now() - heartbeatStartTime;
|
|
return elapsed < STARTUP_DURATION ? HEARTBEAT_INTERVAL_FAST : HEARTBEAT_INTERVAL_SLOW;
|
|
}
|
|
|
|
function sendHeartbeat() {
|
|
mySeq++;
|
|
|
|
seenPeers.set(MY_ID, { seq: mySeq, lastSeen: Date.now() });
|
|
|
|
const sig = crypto
|
|
.sign(null, Buffer.from(`seq:${mySeq}`), privateKey)
|
|
.toString("hex");
|
|
const heartbeat =
|
|
JSON.stringify({
|
|
type: "HEARTBEAT",
|
|
id: MY_ID,
|
|
seq: mySeq,
|
|
hops: 0,
|
|
nonce: MY_NONCE,
|
|
sig,
|
|
}) + "\n";
|
|
for (const socket of swarm.connections) {
|
|
socket.write(heartbeat);
|
|
}
|
|
|
|
const now = Date.now();
|
|
let changed = false;
|
|
for (const [id, data] of seenPeers) {
|
|
if (now - data.lastSeen > PEER_STALE_TIMEOUT) {
|
|
seenPeers.delete(id);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) broadcastUpdate();
|
|
|
|
// Schedule next heartbeat with adaptive interval
|
|
setTimeout(sendHeartbeat, getHeartbeatInterval());
|
|
}
|
|
|
|
// Start heartbeat loop on first connection
|
|
function startHeartbeatIfNeeded() {
|
|
if (!heartbeatStarted) {
|
|
heartbeatStarted = true;
|
|
heartbeatStartTime = Date.now();
|
|
console.log("[P2P] First connection established, starting fast heartbeat...");
|
|
sendHeartbeat();
|
|
}
|
|
}
|
|
|
|
// Graceful Shutdown
|
|
function handleShutdown() {
|
|
console.log("[P2P] Shutting down, sending goodbye...");
|
|
const goodbye = JSON.stringify({ type: "LEAVE", id: MY_ID, hops: 0 }) + "\n";
|
|
for (const socket of swarm.connections) {
|
|
socket.write(goodbye);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
process.exit(0);
|
|
}, 500);
|
|
}
|
|
|
|
process.on("SIGINT", handleShutdown);
|
|
process.on("SIGTERM", handleShutdown);
|
|
|
|
// --- WEB SERVER ---
|
|
|
|
app.get("/", (req, res) => {
|
|
const count = peerCounter.count(); // HyperLogLog approximate count
|
|
const directPeers = swarm.connections.size;
|
|
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Hypermind</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
background: #111;
|
|
color: #eee;
|
|
margin: 0;
|
|
}
|
|
.container { text-align: center; position: relative; z-index: 10; }
|
|
#network { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
|
|
.count { font-size: 8rem; font-weight: bold; color: #4ade80; transition: color 0.2s; }
|
|
.label { font-size: 1.5rem; color: #9ca3af; margin-top: 1rem; }
|
|
.footer { margin-top: 2rem; font-size: 0.9rem; color: #4b5563; }
|
|
.debug { font-size: 0.8rem; color: #333; margin-top: 1rem; }
|
|
a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; }
|
|
.pulse { animation: pulse 0.5s ease-in-out; }
|
|
@keyframes pulse {
|
|
0% { transform: scale(1); }
|
|
50% { transform: scale(1.1); color: #fff; }
|
|
100% { transform: scale(1); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="network"></canvas>
|
|
<div class="container">
|
|
<div id="count" class="count">${count}</div>
|
|
<div class="label">Active Nodes</div>
|
|
<div class="footer">
|
|
powered by <a href="https://github.com/lklynet/hypermind" target="_blank">hypermind</a>
|
|
</div>
|
|
<div class="debug">
|
|
ID: ${MY_ID.slice(0, 8)}...<br>
|
|
Direct Connections: <span id="direct">${directPeers}</span>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const countEl = document.getElementById('count');
|
|
const directEl = document.getElementById('direct');
|
|
|
|
// Particle System
|
|
const canvas = document.getElementById('network');
|
|
const ctx = canvas.getContext('2d');
|
|
let particles = [];
|
|
|
|
function resize() {
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
}
|
|
window.addEventListener('resize', resize);
|
|
resize();
|
|
|
|
class Particle {
|
|
constructor() {
|
|
this.x = Math.random() * canvas.width;
|
|
this.y = Math.random() * canvas.height;
|
|
this.vx = (Math.random() - 0.5) * 1;
|
|
this.vy = (Math.random() - 0.5) * 1;
|
|
this.size = 3;
|
|
}
|
|
|
|
update() {
|
|
this.x += this.vx;
|
|
this.y += this.vy;
|
|
|
|
if (this.x < 0 || this.x > canvas.width) this.vx *= -1;
|
|
if (this.y < 0 || this.y > canvas.height) this.vy *= -1;
|
|
}
|
|
|
|
draw() {
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#4ade80';
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function updateParticles(count) {
|
|
// Limit visual particles to 500 to prevent browser crash
|
|
const VISUAL_LIMIT = 500;
|
|
const visualCount = Math.min(count, VISUAL_LIMIT);
|
|
|
|
const currentCount = particles.length;
|
|
if (visualCount > currentCount) {
|
|
for (let i = 0; i < visualCount - currentCount; i++) {
|
|
particles.push(new Particle());
|
|
}
|
|
} else if (visualCount < currentCount) {
|
|
particles.splice(visualCount, currentCount - visualCount);
|
|
}
|
|
}
|
|
|
|
// Initialize with server-rendered count
|
|
updateParticles(${count});
|
|
|
|
function animate() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw connections
|
|
ctx.strokeStyle = 'rgba(74, 222, 128, 0.15)';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i < particles.length; i++) {
|
|
for (let j = i + 1; j < particles.length; j++) {
|
|
const dx = particles[i].x - particles[j].x;
|
|
const dy = particles[i].y - particles[j].y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance < 150) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(particles[i].x, particles[i].y);
|
|
ctx.lineTo(particles[j].x, particles[j].y);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
particles.forEach(p => {
|
|
p.update();
|
|
p.draw();
|
|
});
|
|
|
|
requestAnimationFrame(animate);
|
|
}
|
|
|
|
animate();
|
|
|
|
// Use Server-Sent Events for realtime updates
|
|
const evtSource = new EventSource("/events");
|
|
|
|
evtSource.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
|
|
updateParticles(data.count);
|
|
|
|
// Only update and animate if changed
|
|
if (countEl.innerText != data.count) {
|
|
countEl.innerText = data.count;
|
|
countEl.classList.remove('pulse');
|
|
void countEl.offsetWidth; // trigger reflow
|
|
countEl.classList.add('pulse');
|
|
}
|
|
|
|
directEl.innerText = data.direct;
|
|
};
|
|
|
|
evtSource.onerror = (err) => {
|
|
console.error("EventSource failed:", err);
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
|
|
// SSE Endpoint
|
|
app.get("/events", (req, res) => {
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.flushHeaders();
|
|
|
|
sseClients.add(res);
|
|
|
|
const data = JSON.stringify({
|
|
count: peerCounter.count(),
|
|
direct: swarm.connections.size,
|
|
id: MY_ID,
|
|
});
|
|
res.write(`data: ${data}\n\n`);
|
|
|
|
req.on("close", () => {
|
|
sseClients.delete(res);
|
|
});
|
|
});
|
|
|
|
app.get("/api/stats", (req, res) => {
|
|
res.json({
|
|
count: peerCounter.count(),
|
|
direct: swarm.connections.size,
|
|
id: MY_ID,
|
|
});
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Hypermind Node running on port ${PORT}`);
|
|
console.log(`ID: ${MY_ID}`);
|
|
});
|