diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..4fe22a9
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,128 @@
+name: Build
+
+on:
+ workflow_call:
+ inputs:
+ push-to-registry:
+ description: "Whether to push images to the container registry"
+ required: false
+ default: false
+ type: boolean
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build:
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ arch: amd64
+ runner: ubuntu-latest
+ test: true
+ - platform: linux/arm64
+ arch: arm64
+ runner: ubuntu-24.04-arm
+ test: true
+ - platform: linux/arm/v7
+ arch: armv7
+ runner: ubuntu-24.04-arm
+ test: false
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to the Container registry
+ if: inputs.push-to-registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ if: inputs.push-to-registry
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
+ - name: Build Docker image
+ id: build
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: ${{ matrix.platform }}
+ labels: ${{ steps.meta.outputs.labels }}
+ load: ${{ matrix.test }}
+ tags: hypermind-test:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Run container for testing
+ if: matrix.test
+ run: docker run -d --name hypermind-test -p 3000:3000 hypermind-test:${{ github.sha }}
+
+ - name: Wait for server to be ready
+ if: matrix.test
+ run: |
+ for i in {1..30}; do
+ if curl -sf http://localhost:3000/api/stats; then
+ echo "Server is ready"
+ exit 0
+ fi
+ echo "Waiting for server... ($i/30)"
+ sleep 1
+ done
+ echo "Server failed to start"
+ docker logs hypermind-test
+ exit 1
+
+ - name: Verify API response
+ if: matrix.test
+ run: |
+ response=$(curl -sf http://localhost:3000/api/stats)
+ echo "Response: $response"
+ echo "$response" | jq -e '.count != null and .id != null'
+
+ - name: Cleanup test container
+ if: always() && matrix.test
+ run: docker rm -f hypermind-test || true
+
+ - name: Push by digest
+ if: inputs.push-to-registry
+ id: push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: ${{ matrix.platform }}
+ labels: ${{ steps.meta.outputs.labels }}
+ outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
+ cache-from: type=gha
+
+ - name: Export digest
+ if: inputs.push-to-registry
+ run: |
+ mkdir -p ${{ runner.temp }}/digests
+ digest="${{ steps.push.outputs.digest }}"
+ touch "${{ runner.temp }}/digests/${digest#sha256:}"
+
+ - name: Upload digest
+ if: inputs.push-to-registry
+ uses: actions/upload-artifact@v4
+ with:
+ name: digests-${{ matrix.arch }}
+ path: ${{ runner.temp }}/digests/*
+ if-no-files-found: error
+ retention-days: 1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..061f9bc
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,13 @@
+name: CI
+
+on:
+ pull_request:
+ branches: ["main"]
+
+jobs:
+ build:
+ uses: ./.github/workflows/build.yml
+ permissions:
+ contents: read
+ with:
+ push-to-registry: false
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index d800435..91976e7 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,8 +1,6 @@
name: Publish Docker Image
on:
- pull_request:
- branches: ["main"]
push:
branches: ["main"]
release:
@@ -15,73 +13,16 @@ env:
jobs:
build:
- runs-on: ${{ matrix.runner }}
+ uses: ./.github/workflows/build.yml
permissions:
contents: read
packages: write
- strategy:
- fail-fast: false
- matrix:
- include:
- - platform: linux/amd64
- runner: ubuntu-latest
- - platform: linux/arm64
- runner: ubuntu-24.04-arm
- - platform: linux/arm/v7
- runner: ubuntu-24.04-arm
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to the Container registry
- if: github.event_name != 'pull_request'
- uses: docker/login-action@v3
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract metadata (tags, labels) for Docker
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
-
- - name: Build and push by digest
- id: build
- uses: docker/build-push-action@v6
- with:
- context: .
- platforms: ${{ matrix.platform }}
- labels: ${{ steps.meta.outputs.labels }}
- outputs: ${{ github.event_name != 'pull_request' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.IMAGE_NAME) || 'type=docker' }}
-
- - name: Export digest
- if: github.event_name != 'pull_request'
- run: |
- mkdir -p ${{ runner.temp }}/digests
- digest="${{ steps.build.outputs.digest }}"
- touch "${{ runner.temp }}/digests/${digest#sha256:}"
-
- - name: Upload digest
- if: github.event_name != 'pull_request'
- uses: actions/upload-artifact@v4
- with:
- name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || matrix.platform == 'linux/arm64' && 'arm64' || 'armv7' }}
- path: ${{ runner.temp }}/digests/*
- if-no-files-found: error
- retention-days: 1
+ secrets: inherit
+ with:
+ push-to-registry: true
merge:
runs-on: ubuntu-latest
- if: github.event_name != 'pull_request'
needs: build
permissions:
contents: read
diff --git a/Dockerfile b/Dockerfile
index d2f5dfd..1ef67a0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,9 @@ COPY package*.json ./
RUN npm ci --omit=dev
+COPY public/ ./public/
COPY server.js hypermind2.svg LICENSE ./
+COPY src/ ./src/
ENV PORT=3000
ENV NODE_ENV=production
diff --git a/README.md b/README.md
index 227ec30..2103205 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,13 @@ services:
```
+## Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `PORT` | `3000` | The port the web dashboard listens on. Since `--network host` is used, this port opens directly on the host. |
+| `MAX_PEERS` | `10000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. |
+
## Usage
Open your browser to: `http://localhost:3000`
diff --git a/docker-compose.yml b/docker-compose.yml
index 9e185ed..fbf9053 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,3 +6,4 @@ services:
restart: unless-stopped
environment:
- PORT=3000
+ - MAX_PEERS=10000
diff --git a/package-lock.json b/package-lock.json
index 960e8d8..350f0e7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "dotenv": "^17.2.3",
"express": "^5.2.1",
"hyperswarm": "^4.16.0"
}
@@ -321,6 +322,18 @@
"udx-native": "^1.5.3"
}
},
+ "node_modules/dotenv": {
+ "version": "17.2.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+ "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
diff --git a/package.json b/package.json
index 8182bbe..8d6ea5e 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"homepage": "https://github.com/lklynet/hypermind#readme",
"type": "commonjs",
"dependencies": {
+ "dotenv": "^17.2.3",
"express": "^5.2.1",
"hyperswarm": "^4.16.0"
}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..248747c
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,141 @@
+const countEl = document.getElementById('count');
+const directEl = document.getElementById('direct');
+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();
+ }
+}
+
+const updateParticles = (count) => {
+ 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);
+ }
+}
+
+const animate = () => {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ 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);
+}
+
+const openDiagnostics = () => {
+ document.getElementById('diagnosticsModal').classList.add('active');
+}
+
+const closeDiagnostics = () => {
+ document.getElementById('diagnosticsModal').classList.remove('active');
+}
+
+document.getElementById('diagnosticsModal').addEventListener('click', (e) => {
+ if (e.target.id === 'diagnosticsModal') {
+ closeDiagnostics();
+ }
+});
+
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ closeDiagnostics();
+ }
+});
+
+const evtSource = new EventSource("/events");
+
+evtSource.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+
+ updateParticles(data.count);
+
+ if (countEl.innerText != data.count) {
+ countEl.innerText = data.count;
+ countEl.classList.remove('pulse');
+ void countEl.offsetWidth;
+ countEl.classList.add('pulse');
+ }
+
+ directEl.innerText = data.direct;
+
+ if (data.diagnostics) {
+ const d = data.diagnostics;
+ document.getElementById('diag-heartbeats-rx').innerText = d.heartbeatsReceived.toLocaleString();
+ document.getElementById('diag-heartbeats-tx').innerText = d.heartbeatsRelayed.toLocaleString();
+ document.getElementById('diag-new-peers').innerText = d.newPeersAdded.toLocaleString();
+ document.getElementById('diag-dup-seq').innerText = d.duplicateSeq.toLocaleString();
+ document.getElementById('diag-invalid-pow').innerText = d.invalidPoW.toLocaleString();
+ document.getElementById('diag-invalid-sig').innerText = d.invalidSig.toLocaleString();
+ document.getElementById('diag-bandwidth-in').innerText = (d.bytesReceived / 1024).toFixed(1) + ' KB';
+ document.getElementById('diag-bandwidth-out').innerText = (d.bytesRelayed / 1024).toFixed(1) + ' KB';
+ document.getElementById('diag-leave').innerText = d.leaveMessages.toLocaleString();
+ }
+};
+
+evtSource.onerror = (err) => {
+ // Removing console error here as it's extremely spammy in the browser console and it will reconnct automatically anyway, so pretty redundant.
+};
+
+const initialCount = parseInt(countEl.dataset.initialCount) || 0;
+countEl.innerText = initialCount;
+countEl.classList.add('loaded');
+updateParticles(initialCount);
+animate();
+
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..60c0ace
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..77d90c0
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,71 @@
+
+
+
+ Hypermind
+
+
+
+
+
+
+
+
{{COUNT}}
+
Active Nodes
+
+
+ ID: {{ID}}
+ Direct Connections: {{DIRECT}}
+ diagnostics
+
+
+
+
+
+
+
Diagnostics
+
+ Heartbeats Received
+ 0
+
+
+ Heartbeats Relayed
+ 0
+
+
+ New Peers Added
+ 0
+
+
+ Duplicate/Old Seq
+ 0
+
+
+ Invalid PoW
+ 0
+
+
+ Invalid Signatures
+ 0
+
+
+ Bandwidth In
+ 0 KB
+
+
+ Bandwidth Out
+ 0 KB
+
+
+ LEAVE Messages
+ 0
+
+
last 10 seconds
+
+
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..cd016e1
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,87 @@
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+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; visibility: hidden; }
+.count.loaded { visibility: visible; }
+.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: #4b5563; margin-top: 1rem; }
+.debug-link { color: #4b5563; border-bottom: 1px dotted #4b5563; cursor: pointer; }
+.debug-link:hover { color: #9ca3af; }
+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); }
+}
+
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.8);
+}
+.modal.active { display: flex; align-items: center; justify-content: center; }
+.modal-content {
+ background: #111;
+ border: 1px solid #222;
+ padding: 2rem;
+ max-width: 500px;
+ width: 90%;
+ position: relative;
+}
+.modal-title {
+ font-size: 0.9rem;
+ color: #666;
+ margin-bottom: 1.5rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+.close-btn {
+ position: absolute;
+ top: 1.5rem;
+ right: 1.5rem;
+ background: none;
+ border: none;
+ color: #333;
+ font-size: 1.2rem;
+ cursor: pointer;
+}
+.close-btn:hover { color: #666; }
+.stat-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid #1a1a1a;
+ font-size: 0.85rem;
+}
+.stat-row:last-child { border-bottom: none; }
+.stat-label { color: #4b5563; }
+.stat-value {
+ color: #9ca3af;
+ font-variant-numeric: tabular-nums;
+}
+.update-time {
+ text-align: center;
+ font-size: 0.7rem;
+ color: #333;
+ margin-top: 1rem;
+}
diff --git a/server.js b/server.js
index a0983ce..9c60c0c 100644
--- a/server.js
+++ b/server.js
@@ -1,646 +1,69 @@
-const express = require("express");
-const Hyperswarm = require("hyperswarm");
-const crypto = require("crypto");
+require('dotenv').config();
-const app = express();
-const PORT = process.env.PORT || 3000;
+const { generateIdentity } = require("./src/core/identity");
+const { PeerManager } = require("./src/state/peers");
+const { DiagnosticsManager } = require("./src/state/diagnostics");
+const { MessageHandler } = require("./src/p2p/messaging");
+const { relayMessage } = require("./src/p2p/relay");
+const { SwarmManager } = require("./src/p2p/swarm");
+const { SSEManager } = require("./src/web/sse");
+const { createServer, startServer } = require("./src/web/server");
+const { DIAGNOSTICS_INTERVAL } = require("./src/config/constants");
-// --- CONFIGURATION ---
-const TOPIC_NAME = "hypermind-lklynet-v1";
-const TOPIC = crypto.createHash("sha256").update(TOPIC_NAME).digest();
+const main = async () => {
+ const identity = generateIdentity();
+ const peerManager = new PeerManager();
+ const diagnostics = new DiagnosticsManager();
+ const sseManager = new SSEManager();
-// 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
+ peerManager.addOrUpdatePeer(identity.id, peerManager.getSeq(), null);
-// --- 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));
- }
+ const broadcastUpdate = () => {
+ sseManager.broadcastUpdate({
+ count: peerManager.size,
+ direct: swarmManager.getSwarm().connections.size,
+ id: identity.id,
+ diagnostics: diagnostics.getStats(),
+ });
+ };
- _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;
- }
+ const messageHandler = new MessageHandler(
+ peerManager,
+ diagnostics,
+ (msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics),
+ broadcastUpdate
+ );
- add(item) {
- for (let i = 0; i < this.hashCount; i++) {
- const idx = this._hash(item, i * 0x9e3779b9);
- this.bits[idx >>> 3] |= (1 << (idx & 7));
- }
- }
+ const swarmManager = new SwarmManager(
+ identity,
+ peerManager,
+ diagnostics,
+ messageHandler,
+ (msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics),
+ broadcastUpdate
+ );
- 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;
- }
+ await swarmManager.start();
- clear() {
- this.bits.fill(0);
- }
-}
+ diagnostics.startLogging(
+ () => peerManager.size,
+ () => swarmManager.getSwarm().connections.size
+ );
-// 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);
- }
+ setInterval(() => {
broadcastUpdate();
- });
+ }, DIAGNOSTICS_INTERVAL);
- socket.on("error", () => {});
-});
+ const app = createServer(identity, peerManager, swarmManager, sseManager, diagnostics);
+ startServer(app, identity);
-const discovery = swarm.join(TOPIC);
-discovery.flushed().then(() => {
- console.log("[P2P] Joined topic:", TOPIC_NAME);
-});
+ const handleShutdown = () => {
+ diagnostics.stopLogging();
+ swarmManager.shutdown();
+ };
-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);
- }
- }
- }
+ process.on("SIGINT", handleShutdown);
+ process.on("SIGTERM", handleShutdown);
}
-// 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(`
-
-
-
- Hypermind
-
-
-
-
-
-
-
-
${count}
-
Active Nodes
-
-
- ID: ${MY_ID.slice(0, 8)}...
- Direct Connections: ${directPeers}
-
-
-
-
-
- `);
-});
-
-// SSE Endpoint
-app.get("/favicon.svg", (req, res) => {
- res.sendFile(__dirname + "/hypermind2.svg");
-});
-
-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}`);
-});
+main().catch(console.error);
diff --git a/src/config/constants.js b/src/config/constants.js
new file mode 100644
index 0000000..7ba02cc
--- /dev/null
+++ b/src/config/constants.js
@@ -0,0 +1,38 @@
+const crypto = require("crypto");
+
+const TOPIC_NAME = "hypermind-lklynet-v1";
+const TOPIC = crypto.createHash("sha256").update(TOPIC_NAME).digest();
+
+/**
+ * fccview here, frankly I don't think I can make this more secure, we can change it to `00000` but
+ * that means until everyone upgrade there'll be a divide between nodes.
+ *
+ * I ran it that way and I was fairly isolated, with hundreds of failed POW, shame.
+ * adding an extra 0 makes it very expensive on attacker to make it worth the fun for them, so maybe consider it.
+ *
+ */
+const POW_PREFIX = "0000";
+
+const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 10000;
+const MAX_MESSAGE_SIZE = 2048;
+const MAX_RELAY_HOPS = 3;
+
+const HEARTBEAT_INTERVAL = 5000;
+const PEER_TIMEOUT = 15000;
+const BROADCAST_THROTTLE = 1000;
+const DIAGNOSTICS_INTERVAL = 10000;
+const PORT = process.env.PORT || 3000;
+
+module.exports = {
+ TOPIC_NAME,
+ TOPIC,
+ POW_PREFIX,
+ MAX_PEERS,
+ MAX_MESSAGE_SIZE,
+ MAX_RELAY_HOPS,
+ HEARTBEAT_INTERVAL,
+ PEER_TIMEOUT,
+ BROADCAST_THROTTLE,
+ DIAGNOSTICS_INTERVAL,
+ PORT,
+};
diff --git a/src/core/identity.js b/src/core/identity.js
new file mode 100644
index 0000000..0c64857
--- /dev/null
+++ b/src/core/identity.js
@@ -0,0 +1,21 @@
+const crypto = require("crypto");
+const { POW_PREFIX } = require("../config/constants");
+
+const generateIdentity = () => {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
+ const id = publicKey.export({ type: "spki", format: "der" }).toString("hex");
+
+ let nonce = 0;
+ while (true) {
+ const hash = crypto
+ .createHash("sha256")
+ .update(id + nonce)
+ .digest("hex");
+ if (hash.startsWith(POW_PREFIX)) break;
+ nonce++;
+ }
+
+ return { publicKey, privateKey, id, nonce };
+}
+
+module.exports = { generateIdentity };
diff --git a/src/core/security.js b/src/core/security.js
new file mode 100644
index 0000000..9544583
--- /dev/null
+++ b/src/core/security.js
@@ -0,0 +1,43 @@
+const crypto = require("crypto");
+const { POW_PREFIX } = require("../config/constants");
+
+const verifyPoW = (id, nonce) => {
+ if (!nonce) return false;
+ const powHash = crypto
+ .createHash("sha256")
+ .update(id + nonce)
+ .digest("hex");
+ return powHash.startsWith(POW_PREFIX);
+}
+
+const signMessage = (message, privateKey) => {
+ return crypto.sign(null, Buffer.from(message), privateKey).toString("hex");
+}
+
+const verifySignature = (message, signature, publicKey) => {
+ try {
+ return crypto.verify(
+ null,
+ Buffer.from(message),
+ publicKey,
+ Buffer.from(signature, "hex")
+ );
+ } catch (e) {
+ return false;
+ }
+}
+
+const createPublicKey = (id) => {
+ return crypto.createPublicKey({
+ key: Buffer.from(id, "hex"),
+ format: "der",
+ type: "spki",
+ });
+}
+
+module.exports = {
+ verifyPoW,
+ signMessage,
+ verifySignature,
+ createPublicKey,
+};
diff --git a/src/p2p/messaging.js b/src/p2p/messaging.js
new file mode 100644
index 0000000..bc5f3ca
--- /dev/null
+++ b/src/p2p/messaging.js
@@ -0,0 +1,132 @@
+const { verifyPoW, verifySignature, createPublicKey } = require("../core/security");
+const { MAX_RELAY_HOPS } = require("../config/constants");
+const { BloomFilterManager } = require("../state/bloom");
+
+class MessageHandler {
+ constructor(peerManager, diagnostics, relayCallback, broadcastCallback) {
+ this.peerManager = peerManager;
+ this.diagnostics = diagnostics;
+ this.relayCallback = relayCallback;
+ this.broadcastCallback = broadcastCallback;
+ this.bloomFilter = new BloomFilterManager();
+ this.bloomFilter.start();
+ }
+
+ handleMessage(msg, sourceSocket) {
+ if (!validateMessage(msg)) {
+ return;
+ }
+
+ if (msg.type === "HEARTBEAT") {
+ this.handleHeartbeat(msg, sourceSocket);
+ } else if (msg.type === "LEAVE") {
+ this.handleLeave(msg, sourceSocket);
+ }
+ }
+
+ handleHeartbeat(msg, sourceSocket) {
+ this.diagnostics.increment("heartbeatsReceived");
+ const { id, seq, hops, nonce, sig } = msg;
+
+ if (!verifyPoW(id, nonce)) {
+ this.diagnostics.increment("invalidPoW");
+ return;
+ }
+
+ const stored = this.peerManager.getPeer(id);
+ if (stored && seq <= stored.seq) {
+ this.diagnostics.increment("duplicateSeq");
+ return;
+ }
+
+ if (!sig) return;
+
+ try {
+ let key;
+ if (stored && stored.key) {
+ key = stored.key;
+ } else {
+ if (!this.peerManager.canAcceptPeer(id)) return;
+ key = createPublicKey(id);
+ }
+
+ if (!verifySignature(`seq:${seq}`, sig, key)) {
+ this.diagnostics.increment("invalidSig");
+ return;
+ }
+
+ if (hops === 0) {
+ sourceSocket.peerId = id;
+ }
+
+ const wasNew = this.peerManager.addOrUpdatePeer(id, seq, key);
+
+ if (wasNew) {
+ this.diagnostics.increment("newPeersAdded");
+ this.broadcastCallback();
+ }
+
+ // Only relay if we haven't already relayed this message (bloom filter check)
+ if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, seq)) {
+ this.bloomFilter.markRelayed(id, seq);
+ this.diagnostics.increment("heartbeatsRelayed");
+ this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket);
+ }
+ } catch (e) {
+ return;
+ }
+ }
+
+ handleLeave(msg, sourceSocket) {
+ this.diagnostics.increment("leaveMessages");
+ const { id, hops, sig } = msg;
+
+ if (!sig) return;
+
+ const stored = this.peerManager.getPeer(id);
+ if (!stored || !stored.key) return;
+
+ if (!verifySignature(`type:LEAVE:${id}`, sig, stored.key)) {
+ this.diagnostics.increment("invalidSig");
+ return;
+ }
+
+ if (this.peerManager.hasPeer(id)) {
+ this.peerManager.removePeer(id);
+ this.broadcastCallback();
+
+ // Use id:leave as key for LEAVE messages
+ if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, "leave")) {
+ this.bloomFilter.markRelayed(id, "leave");
+ this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket);
+ }
+ }
+ }
+}
+
+const validateMessage = (msg) => {
+ if (!msg || typeof msg !== 'object') return false;
+ if (!msg.type) return false;
+
+ const msgSize = JSON.stringify(msg).length;
+ if (msgSize > require("../config/constants").MAX_MESSAGE_SIZE) return false;
+
+ if (msg.type === "HEARTBEAT") {
+ const allowedFields = ['type', 'id', 'seq', 'hops', 'nonce', 'sig'];
+ const fields = Object.keys(msg);
+ return fields.every(f => allowedFields.includes(f)) &&
+ msg.id && typeof msg.seq === 'number' &&
+ typeof msg.hops === 'number' && msg.nonce && msg.sig;
+ }
+
+ if (msg.type === "LEAVE") {
+ const allowedFields = ['type', 'id', 'hops', 'sig'];
+ const fields = Object.keys(msg);
+ return fields.every(f => allowedFields.includes(f)) &&
+ msg.id && typeof msg.hops === 'number' && msg.sig;
+ }
+
+ return false;
+}
+
+module.exports = { MessageHandler, validateMessage };
diff --git a/src/p2p/relay.js b/src/p2p/relay.js
new file mode 100644
index 0000000..1bc6745
--- /dev/null
+++ b/src/p2p/relay.js
@@ -0,0 +1,16 @@
+const relayMessage = (msg, sourceSocket, swarm, diagnostics) => {
+ const data = JSON.stringify(msg) + "\n";
+ const relayCount = swarm.connections.size - 1;
+
+ if (diagnostics) {
+ diagnostics.increment("bytesRelayed", data.length * relayCount);
+ }
+
+ for (const socket of swarm.connections) {
+ if (socket !== sourceSocket) {
+ socket.write(data);
+ }
+ }
+}
+
+module.exports = { relayMessage };
diff --git a/src/p2p/swarm.js b/src/p2p/swarm.js
new file mode 100644
index 0000000..6c4292b
--- /dev/null
+++ b/src/p2p/swarm.js
@@ -0,0 +1,118 @@
+const Hyperswarm = require("hyperswarm");
+const { signMessage } = require("../core/security");
+const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL } = require("../config/constants");
+
+class SwarmManager {
+ constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn) {
+ this.identity = identity;
+ this.peerManager = peerManager;
+ this.diagnostics = diagnostics;
+ this.messageHandler = messageHandler;
+ this.relayFn = relayFn;
+ this.broadcastFn = broadcastFn;
+
+ this.swarm = new Hyperswarm();
+ this.heartbeatInterval = null;
+ }
+
+ async start() {
+ this.swarm.on("connection", (socket) => this.handleConnection(socket));
+
+ const discovery = this.swarm.join(TOPIC);
+ await discovery.flushed();
+
+ this.startHeartbeat();
+ }
+
+ handleConnection(socket) {
+ const sig = signMessage(`seq:${this.peerManager.getSeq()}`, this.identity.privateKey);
+ const hello = JSON.stringify({
+ type: "HEARTBEAT",
+ id: this.identity.id,
+ seq: this.peerManager.getSeq(),
+ hops: 0,
+ nonce: this.identity.nonce,
+ sig,
+ });
+ socket.write(hello);
+ this.broadcastFn();
+
+ socket.on("data", (data) => {
+ this.diagnostics.increment("bytesReceived", data.length);
+ try {
+ const msgs = data
+ .toString()
+ .split("\n")
+ .filter((x) => x.trim());
+ for (const msgStr of msgs) {
+ const msg = JSON.parse(msgStr);
+ this.messageHandler.handleMessage(msg, socket);
+ }
+ } catch (e) {
+ }
+ });
+
+ socket.on("close", () => {
+ if (socket.peerId && this.peerManager.hasPeer(socket.peerId)) {
+ this.peerManager.removePeer(socket.peerId);
+ }
+ this.broadcastFn();
+ });
+
+ socket.on("error", () => { });
+ }
+
+ startHeartbeat() {
+ this.heartbeatInterval = setInterval(() => {
+ const seq = this.peerManager.incrementSeq();
+ this.peerManager.addOrUpdatePeer(this.identity.id, seq, null);
+
+ const sig = signMessage(`seq:${seq}`, this.identity.privateKey);
+ const heartbeat = JSON.stringify({
+ type: "HEARTBEAT",
+ id: this.identity.id,
+ seq,
+ hops: 0,
+ nonce: this.identity.nonce,
+ sig,
+ }) + "\n";
+
+ for (const socket of this.swarm.connections) {
+ socket.write(heartbeat);
+ }
+
+ const removed = this.peerManager.cleanupStalePeers();
+ if (removed > 0) {
+ this.broadcastFn();
+ }
+ }, HEARTBEAT_INTERVAL);
+ }
+
+ shutdown() {
+ const sig = signMessage(`type:LEAVE:${this.identity.id}`, this.identity.privateKey);
+ const goodbye = JSON.stringify({
+ type: "LEAVE",
+ id: this.identity.id,
+ hops: 0,
+ sig,
+ }) + "\n";
+
+ for (const socket of this.swarm.connections) {
+ socket.write(goodbye);
+ }
+
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ }
+
+ setTimeout(() => {
+ process.exit(0);
+ }, 500);
+ }
+
+ getSwarm() {
+ return this.swarm;
+ }
+}
+
+module.exports = { SwarmManager };
diff --git a/src/state/bloom.js b/src/state/bloom.js
new file mode 100644
index 0000000..f0a6c04
--- /dev/null
+++ b/src/state/bloom.js
@@ -0,0 +1,78 @@
+/**
+ * Simple Bloom filter for message deduplication
+ * Prevents 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 manager
+ * Rotates every 30 seconds to prevent unbounded growth
+ */
+class BloomFilterManager {
+ constructor() {
+ this.currentBloom = new BloomFilter();
+ this.previousBloom = new BloomFilter();
+ this.rotationInterval = null;
+ }
+
+ start() {
+ this.rotationInterval = setInterval(() => {
+ this.previousBloom = this.currentBloom;
+ this.currentBloom = new BloomFilter();
+ }, 30000);
+ }
+
+ stop() {
+ if (this.rotationInterval) {
+ clearInterval(this.rotationInterval);
+ this.rotationInterval = null;
+ }
+ }
+
+ hasRelayed(id, seq) {
+ const key = `${id}:${seq}`;
+ return this.currentBloom.has(key) || this.previousBloom.has(key);
+ }
+
+ markRelayed(id, seq) {
+ const key = `${id}:${seq}`;
+ this.currentBloom.add(key);
+ }
+}
+
+module.exports = { BloomFilter, BloomFilterManager };
diff --git a/src/state/diagnostics.js b/src/state/diagnostics.js
new file mode 100644
index 0000000..659e406
--- /dev/null
+++ b/src/state/diagnostics.js
@@ -0,0 +1,48 @@
+const { DIAGNOSTICS_INTERVAL } = require("../config/constants");
+
+class DiagnosticsManager {
+ constructor() {
+ this.stats = {
+ heartbeatsReceived: 0,
+ heartbeatsRelayed: 0,
+ invalidPoW: 0,
+ duplicateSeq: 0,
+ invalidSig: 0,
+ newPeersAdded: 0,
+ bytesReceived: 0,
+ bytesRelayed: 0,
+ leaveMessages: 0,
+ };
+
+ this.interval = null;
+ }
+
+ increment(key, amount = 1) {
+ if (this.stats.hasOwnProperty(key)) {
+ this.stats[key] += amount;
+ }
+ }
+
+ getStats() {
+ return { ...this.stats };
+ }
+
+ reset() {
+ Object.keys(this.stats).forEach(k => this.stats[k] = 0);
+ }
+
+ startLogging(getPeerCount, getConnectionCount) {
+ this.interval = setInterval(() => {
+ this.reset();
+ }, DIAGNOSTICS_INTERVAL);
+ }
+
+ stopLogging() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ this.interval = null;
+ }
+ }
+}
+
+module.exports = { DiagnosticsManager };
diff --git a/src/state/hyperloglog.js b/src/state/hyperloglog.js
new file mode 100644
index 0000000..631e38b
--- /dev/null
+++ b/src/state/hyperloglog.js
@@ -0,0 +1,66 @@
+class HyperLogLog {
+ constructor(precision = 10) {
+ this.precision = precision;
+ this.registerCount = 1 << precision;
+ this.registers = new Uint8Array(this.registerCount);
+ this.alphaMM = this._getAlpha() * this.registerCount * this.registerCount;
+ }
+
+ _getAlpha() {
+ 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) {
+ 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);
+ const registerIndex = hash >>> (32 - this.precision);
+ const remainingBits = hash << this.precision;
+ const leadingZeros = this._countLeadingZeros(remainingBits, 32 - this.precision) + 1;
+
+ if (leadingZeros > this.registers[registerIndex]) {
+ this.registers[registerIndex] = leadingZeros;
+ }
+ }
+
+ count() {
+ 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;
+
+ if (estimate <= 2.5 * this.registerCount && zeroRegisters > 0) {
+ estimate = this.registerCount * Math.log(this.registerCount / zeroRegisters);
+ }
+
+ return Math.round(estimate);
+ }
+}
+
+module.exports = { HyperLogLog };
diff --git a/src/state/peers.js b/src/state/peers.js
new file mode 100644
index 0000000..f54b312
--- /dev/null
+++ b/src/state/peers.js
@@ -0,0 +1,66 @@
+const { MAX_PEERS, PEER_TIMEOUT } = require("../config/constants");
+
+class PeerManager {
+ constructor() {
+ this.seenPeers = new Map();
+ this.mySeq = 0;
+ }
+
+ addOrUpdatePeer(id, seq, key) {
+ const stored = this.seenPeers.get(id);
+ const wasNew = !stored;
+
+ this.seenPeers.set(id, {
+ seq,
+ lastSeen: Date.now(),
+ key,
+ });
+
+ return wasNew;
+ }
+
+ canAcceptPeer(id) {
+ if (this.seenPeers.has(id)) return true;
+ return this.seenPeers.size < MAX_PEERS;
+ }
+
+ getPeer(id) {
+ return this.seenPeers.get(id);
+ }
+
+ removePeer(id) {
+ return this.seenPeers.delete(id);
+ }
+
+ hasPeer(id) {
+ return this.seenPeers.has(id);
+ }
+
+ cleanupStalePeers() {
+ const now = Date.now();
+ let removed = 0;
+
+ for (const [id, data] of this.seenPeers) {
+ if (now - data.lastSeen > PEER_TIMEOUT) {
+ this.seenPeers.delete(id);
+ removed++;
+ }
+ }
+
+ return removed;
+ }
+
+ get size() {
+ return this.seenPeers.size;
+ }
+
+ incrementSeq() {
+ return ++this.mySeq;
+ }
+
+ getSeq() {
+ return this.mySeq;
+ }
+}
+
+module.exports = { PeerManager };
diff --git a/src/web/routes.js b/src/web/routes.js
new file mode 100644
index 0000000..f5e5ab3
--- /dev/null
+++ b/src/web/routes.js
@@ -0,0 +1,56 @@
+const express = require("express");
+const fs = require("fs");
+const path = require("path");
+
+const HTML_TEMPLATE = fs.readFileSync(
+ path.join(__dirname, "../../public/index.html"),
+ "utf-8"
+);
+
+const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) => {
+ app.get("/", (req, res) => {
+ const count = peerManager.size;
+ const directPeers = swarm.getSwarm().connections.size;
+
+ const html = HTML_TEMPLATE
+ .replace(/\{\{COUNT\}\}/g, count)
+ .replace(/\{\{ID\}\}/g, identity.id.slice(0, 8) + "...")
+ .replace(/\{\{DIRECT\}\}/g, directPeers);
+
+ res.send(html);
+ });
+
+ 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();
+
+ sseManager.addClient(res);
+
+ const data = JSON.stringify({
+ count: peerManager.size,
+ direct: swarm.getSwarm().connections.size,
+ id: identity.id,
+ diagnostics: diagnostics.getStats(),
+ });
+ res.write(`data: ${data}\n\n`);
+
+ req.on("close", () => {
+ sseManager.removeClient(res);
+ });
+ });
+
+ app.get("/api/stats", (req, res) => {
+ res.json({
+ count: peerManager.size,
+ direct: swarm.getSwarm().connections.size,
+ id: identity.id,
+ diagnostics: diagnostics.getStats(),
+ });
+ });
+
+ app.use(express.static(path.join(__dirname, "../../public")));
+}
+
+module.exports = { setupRoutes };
diff --git a/src/web/server.js b/src/web/server.js
new file mode 100644
index 0000000..00a4409
--- /dev/null
+++ b/src/web/server.js
@@ -0,0 +1,20 @@
+const express = require("express");
+const { PORT } = require("../config/constants");
+const { setupRoutes } = require("./routes");
+
+const createServer = (identity, peerManager, swarm, sseManager, diagnostics) => {
+ const app = express();
+
+ setupRoutes(app, identity, peerManager, swarm, sseManager, diagnostics);
+
+ return app;
+}
+
+const startServer = (app, identity) => {
+ app.listen(PORT, () => {
+ console.log(`Hypermind Node running on port ${PORT}`);
+ console.log(`ID: ${identity.id}`);
+ });
+}
+
+module.exports = { createServer, startServer };
diff --git a/src/web/sse.js b/src/web/sse.js
new file mode 100644
index 0000000..c80f3cc
--- /dev/null
+++ b/src/web/sse.js
@@ -0,0 +1,33 @@
+const { BROADCAST_THROTTLE } = require("../config/constants");
+
+class SSEManager {
+ constructor() {
+ this.clients = new Set();
+ this.lastBroadcast = 0;
+ }
+
+ addClient(res) {
+ this.clients.add(res);
+ }
+
+ removeClient(res) {
+ this.clients.delete(res);
+ }
+
+ broadcastUpdate(data) {
+ const now = Date.now();
+ if (now - this.lastBroadcast < BROADCAST_THROTTLE) return;
+ this.lastBroadcast = now;
+
+ const message = JSON.stringify(data);
+ for (const client of this.clients) {
+ client.write(`data: ${message}\n\n`);
+ }
+ }
+
+ get size() {
+ return this.clients.size;
+ }
+}
+
+module.exports = { SSEManager };