Merge branch 'main' into custom-css-themes

This commit is contained in:
Owen G
2026-01-06 19:11:00 -07:00
committed by GitHub
18 changed files with 1035 additions and 160 deletions
+19
View File
@@ -0,0 +1,19 @@
# These are supported funding model platforms
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
#patreon: # Replace with a single Patreon username
#open_collective: # Replace with a single Open Collective username
#ko_fi: # Replace with a single Ko-fi username
#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
#liberapay: # Replace with a single Liberapay username
#issuehunt: # Replace with a single IssueHunt username
#lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
#polar: # Replace with a single Polar username
#buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
#thanks_dev: # Replace with a single thanks.dev username
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
github: lklynet
buy_me_a_coffee: lkly
thanks_dev: lklynet
+50 -1
View File
@@ -81,6 +81,33 @@ kubectl expose deployment hypermind --type=LoadBalancer --port=3000 --target-por
```
## » Configuration
You can customize Hypermind's behavior using environment variables.
### Extras
These features are disabled by default. Set them to `true` to enable.
| Variable | Default | Description |
|----------|---------|-------------|
| `ENABLE_CHAT` | `false` | Enables the decentralized chat system. |
| `ENABLE_MAP` | `false` | Enables map visualization features. |
### Refinement
Tune the network parameters to fit your system resources. The defaults are safe for most users. Don't change unless you know what you're doing.
| Variable | Default | Description |
|----------|---------|-------------|
| `MAX_PEERS` | `50000` | Maximum number of peers to track in memory. |
| `MAX_MESSAGE_SIZE` | `2048` | Maximum size of a single message in bytes. |
| `MAX_RELAY_HOPS` | `2` | Maximum number of times a message is relayed. |
| `MAX_CONNECTIONS` | `15` | Maximum number of active P2P connections. |
| `HEARTBEAT_INTERVAL` | `30000` | How often (ms) to send heartbeat messages. |
| `CONNECTION_ROTATION_INTERVAL` | `300000` | How often (ms) to rotate connections. |
| `PEER_TIMEOUT` | `45000` | Time (ms) before a silent peer is considered offline. |
| `CHAT_RATE_LIMIT` | `5000` | Time window (ms) for chat rate limiting. |
| `VISUAL_LIMIT` | `500` | Max number of particles to render on the dashboard. |
## » Ecosystem & Integrations
The community has bravely stepped up to integrate Hypermind into critical monitoring infrastructure.
@@ -125,7 +152,29 @@ See detailed [instructions](https://gethomepage.dev/configs/services/#icons).
| 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` | `1000000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. |
| `MAX_PEERS` | `50000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. |
| `ENABLE_CHAT` | `false` | Set to `true` to enable the ephemeral P2P chat terminal. |
## » Features
### 1. The Counter
It counts. That's the main thing.
### 2. Ephemeral Chat
**New:** A completely decentralized, ephemeral chat system built directly on top of the swarm topology.
* **Ephemeral:** No database. No history. If you refresh, it's gone.
* **Restricted (Default):** You can only talk to your ~32 direct connections.
* **Global Mode:** Use `/global your message` to broadcast messages to the entire swarm (relayed via gossip).
* **Chaotic:** Every 30 seconds, the network rotates your connections. You might be mid-sentence and—*poof*—your audience changes.
* **Anonymous:** You are identified only by the last 4 characters of your node ID.
To enable this feature, set `ENABLE_CHAT=true`.
**Commands:**
* `/global on` - Enable global chat mode.
* `/global off` - Disable global chat mode (local only).
* `/global <message>` - Send a single message to the global swarm without switching modes.
## » Usage
+15 -1
View File
@@ -6,4 +6,18 @@ services:
restart: unless-stopped
environment:
- PORT=3000
- MAX_PEERS=10000
# --- Add-ons ---
# - ENABLE_CHAT=false # Enable decentralized chat
# - ENABLE_MAP=false # Enable peer map visualization
# --- Refinements ---
# - MAX_PEERS=50000 # Max peers to track in memory
# - MAX_CONNECTIONS=15 # Max active P2P connections
# - HEARTBEAT_INTERVAL=30000 # Heartbeat frequency (ms)
# - CONNECTION_ROTATION_INTERVAL=300000 # Connection rotation frequency (ms)
# - PEER_TIMEOUT=45000 # Time before peer is considered offline (ms)
# - MAX_MESSAGE_SIZE=2048 # Max message size (bytes)
# - MAX_RELAY_HOPS=2 # Max message relay hops
# - CHAT_RATE_LIMIT=5000 # Chat rate limit window (ms)
# - VISUAL_LIMIT=500 # Max particles on dashboard
+2
View File
@@ -5,6 +5,8 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"start:dev1": "PORT=3000 ENABLE_CHAT=true TOPIC_NAME=hypermind-dev node server.js",
"start:dev2": "PORT=3001 ENABLE_CHAT=true TOPIC_NAME=hypermind-dev node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
+336 -1
View File
@@ -42,7 +42,8 @@ class Particle {
}
const updateParticles = (count) => {
const VISUAL_LIMIT = 500;
const limitAttr = canvas.getAttribute('data-visual-limit');
const VISUAL_LIMIT = limitAttr ? parseInt(limitAttr) : 500;
const visualCount = Math.min(count, VISUAL_LIMIT);
const currentCount = particles.length;
@@ -100,6 +101,295 @@ document.getElementById('diagnosticsModal').addEventListener('click', (e) => {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeDiagnostics();
closeMap();
}
});
// Map Logic
let map = null;
let mapInitialized = false;
let peerMarkers = {}; // id -> marker
let ipCache = {}; // ip -> { lat, lon }
let lastPeerData = [];
let myLocation = null;
const fetchMyLocation = async () => {
if (myLocation) return;
try {
const res = await fetch('https://ipwho.is/');
const data = await res.json();
if (data.success) {
myLocation = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country };
updateMap(lastPeerData);
}
} catch (e) {
console.error('My location fetch failed', e);
}
}
const openMap = () => {
document.getElementById('mapModal').classList.add('active');
if (!mapInitialized) {
initMap();
} else {
setTimeout(() => {
map.invalidateSize();
}, 100);
}
fetchMyLocation();
if (lastPeerData.length > 0) {
updateMap(lastPeerData);
}
}
const closeMap = () => {
document.getElementById('mapModal').classList.remove('active');
}
document.getElementById('mapModal').addEventListener('click', (e) => {
if (e.target.id === 'mapModal') {
closeMap();
}
});
const initMap = () => {
if (mapInitialized) return;
map = L.map('map').setView([20, 0], 2);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
mapInitialized = true;
setTimeout(() => {
map.invalidateSize();
}, 100);
}
const fetchLocation = async (ip) => {
if (ipCache[ip]) return ipCache[ip];
// Skip local IPs
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.16.')) {
return null;
}
try {
const res = await fetch(`https://ipwho.is/${ip}`);
const data = await res.json();
if (data.success) {
const loc = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country };
ipCache[ip] = loc;
return loc;
}
} catch (e) {
console.error('Geo fetch failed', e);
}
return null;
}
const updateMap = async (peers) => {
if (!mapInitialized) return;
if (!peers) peers = [];
const currentIds = new Set(peers.map(p => p.id));
// Remove old markers
for (const id in peerMarkers) {
if (id !== 'me' && !currentIds.has(id)) {
map.removeLayer(peerMarkers[id]);
delete peerMarkers[id];
}
}
// Add/Update markers
for (const peer of peers) {
if (!peer.ip) continue;
if (!peerMarkers[peer.id]) {
const loc = await fetchLocation(peer.ip);
if (loc) {
const marker = L.circleMarker([loc.lat, loc.lon], {
radius: 10,
fillColor: "#4ade80",
color: "transparent",
weight: 0,
opacity: 0,
fillOpacity: 0.15
}).addTo(map);
marker.bindPopup(`<b>Node</b> ${peer.id.slice(-8)}<br>${loc.city}, ${loc.country}`);
peerMarkers[peer.id] = marker;
}
}
}
// Add My Location
if (myLocation && !peerMarkers['me']) {
const marker = L.circleMarker([myLocation.lat, myLocation.lon], {
radius: 6,
fillColor: "#ffffff",
color: "#4ade80",
weight: 2,
opacity: 1,
fillOpacity: 1
}).addTo(map);
marker.bindPopup(`<b>This Node</b><br>${myLocation.city}, ${myLocation.country}`);
peerMarkers['me'] = marker;
}
}
const terminal = document.getElementById('terminal');
const terminalOutput = document.getElementById('terminal-output');
const systemStatusBar = document.getElementById('system-status-bar');
const terminalInput = document.getElementById('terminal-input');
const terminalToggle = document.getElementById('terminal-toggle');
const mapContainer = document.getElementById('map-container');
const promptEl = document.querySelector('.prompt');
let myId = null;
let myChatHistory = [];
let globalChatEnabled = true;
terminalToggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleChat();
});
// Initialize chat state from localStorage
const initChatState = () => {
const isCollapsed = localStorage.getItem('chatCollapsed') === 'true';
if (isCollapsed) {
terminal.classList.add('collapsed');
terminalToggle.innerText = '▲';
document.body.classList.remove('chat-active');
document.body.classList.add('chat-collapsed');
} else {
terminal.classList.remove('collapsed');
terminalToggle.innerText = '▼';
document.body.classList.add('chat-active');
document.body.classList.remove('chat-collapsed');
}
};
const toggleChat = () => {
terminal.classList.toggle('collapsed');
const isCollapsed = terminal.classList.contains('collapsed');
terminalToggle.innerText = isCollapsed ? '▲' : '▼';
localStorage.setItem('chatCollapsed', isCollapsed);
if (isCollapsed) {
document.body.classList.remove('chat-active');
document.body.classList.add('chat-collapsed');
} else {
document.body.classList.add('chat-active');
document.body.classList.remove('chat-collapsed');
terminalOutput.scrollTop = terminalOutput.scrollHeight;
}
}
const updatePromptStatus = () => {
const now = Date.now();
myChatHistory = myChatHistory.filter(t => now - t < 10000);
if (myChatHistory.length >= 5) {
promptEl.style.color = 'orange';
} else {
promptEl.style.color = '#4ade80';
}
};
setInterval(updatePromptStatus, 500);
const getColorFromId = (id) => {
if (!id) return '#666';
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = id.charCodeAt(i) + ((hash << 5) - hash);
}
const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
return '#' + "00000".substring(0, 6 - c.length) + c;
}
const appendMessage = (msg) => {
const div = document.createElement('div');
if (msg.type === 'CHAT') {
const senderColor = getColorFromId(msg.sender);
const senderName = msg.sender === myId ? 'You' : msg.sender.slice(-4);
const scopeLabel = msg.scope === 'GLOBAL' ? '[GLOBAL] ' : '';
const senderSpan = document.createElement('span');
senderSpan.className = 'msg-sender';
senderSpan.style.color = senderColor;
senderSpan.innerText = `${scopeLabel}[${senderName}]`;
const contentSpan = document.createElement('span');
contentSpan.className = 'msg-content';
contentSpan.innerText = ` > ${msg.content}`;
div.appendChild(senderSpan);
div.appendChild(contentSpan);
}
terminalOutput.appendChild(div);
terminalOutput.scrollTop = terminalOutput.scrollHeight;
}
terminalInput.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') {
let content = terminalInput.value.trim();
if (!content) return;
terminalInput.value = '';
if (content === '/global on') {
globalChatEnabled = true;
systemStatusBar.innerText = `[SYSTEM] Global chat messages enabled.`;
return;
}
if (content === '/global off') {
globalChatEnabled = false;
systemStatusBar.innerText = `[SYSTEM] Global chat messages disabled.`;
return;
}
let scope = 'LOCAL';
if (content.startsWith('/global ')) {
scope = 'GLOBAL';
content = content.replace(/^\/global\s+/, '').trim();
if (!content) return;
}
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, scope })
});
if (res.ok) {
myChatHistory.push(Date.now());
updatePromptStatus();
} else if (res.status === 429) {
// Force update if we hit the limit unexpectedly
// Add a dummy timestamp to force the limit state if not already there
if (myChatHistory.length < 5) {
myChatHistory.push(Date.now());
}
updatePromptStatus();
}
} catch (err) {
console.error('Failed to send message', err);
}
}
});
@@ -108,6 +398,51 @@ const evtSource = new EventSource("/events");
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'SYSTEM') {
systemStatusBar.innerText = `[SYSTEM] ${data.content}`;
return;
}
if (data.type === 'CHAT') {
if (data.scope === 'GLOBAL' && !globalChatEnabled) {
return;
}
appendMessage(data);
return;
}
if (data.chatEnabled) {
terminal.classList.remove('hidden');
// Only initialize state once when chat becomes enabled
if (!terminal.dataset.initialized) {
initChatState();
terminal.dataset.initialized = 'true';
}
} else {
terminal.classList.add('hidden');
document.body.classList.remove('chat-active');
document.body.classList.remove('chat-collapsed');
}
if (data.mapEnabled) {
if (mapContainer) mapContainer.style.display = 'inline';
} else {
if (mapContainer) mapContainer.style.display = 'none';
if (document.getElementById('mapModal').classList.contains('active')) {
closeMap();
}
}
if (data.id) myId = data.id;
if (data.peers) {
lastPeerData = data.peers;
if (mapInitialized && document.getElementById('mapModal').classList.contains('active')) {
updateMap(data.peers);
}
}
updateParticles(data.count);
if (countEl.innerText != data.count) {
+20 -1
View File
@@ -6,9 +6,11 @@
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=palette" /> <link rel="stylesheet" href="/themes/tokyo-night.css" id="theme-css">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head>
<body>
<canvas id="network"></canvas>
<canvas id="network" data-visual-limit="{{VISUAL_LIMIT}}"></canvas>
<div class="container">
<div id="count" class="count" data-initial-count="{{COUNT}}">{{COUNT}}</div>
<div class="label">Active Nodes</div>
@@ -19,6 +21,14 @@
ID: {{ID}}<br>
Direct Connections: <span id="direct">{{DIRECT}}</span><br>
<span class="debug-link" onclick="openDiagnostics()">diagnostics</span>
<span id="map-container" class="{{MAP_CLASS}}"> | <span class="debug-link" id="map-link" onclick="openMap()">map</span></span>
</div>
</div>
<div id="mapModal" class="modal">
<div class="modal-content map-content">
<button class="close-btn" onclick="closeMap()">×</button>
<div id="map"></div>
</div>
</div>
@@ -69,6 +79,15 @@
<button id="theme-switcher" class="theme-btn" title="Cycle Themes">
<span class="material-symbols-outlined">palette</span>
</button>
<div id="terminal" class="terminal hidden">
<button id="terminal-toggle" class="terminal-toggle" title="Toggle Chat"></button>
<div id="system-status-bar" class="system-status-bar"></div>
<div id="terminal-output" class="terminal-output"></div>
<div class="terminal-input-line">
<span class="prompt">&gt;</span>
<input type="text" id="terminal-input" maxlength="140" placeholder="Broadcast..." autocomplete="off">
</div>
</div>
<script src="/app.js"></script>
</body>
+184 -6
View File
@@ -9,18 +9,36 @@ body {
background: var(--color-bg-main);
color: var(--color-text-default);
margin: 0;
transition: padding-bottom 0.3s ease;
}
body.chat-active {
padding-bottom: 250px;
}
body.chat-collapsed {
padding-bottom: 40px;
}
.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: var(--color-count); transition: color 0.2s; visibility: hidden; }
.count.loaded { visibility: visible; }
.label { font-size: 1.5rem; color: var(--color-text-main-label); margin-top: 1rem; }
.footer { margin-top: 2rem; font-size: 0.9rem; color: var(--color-text-footer); }
.debug { font-size: 0.8rem; color: var(--color-text-debug); margin-top: 1rem; }
.debug-link { color: var(--color-text-debug-link); border-bottom: 1px dotted; cursor: pointer; transition: color 0.2s; }
.debug-link:hover { color: var(--color-text-debug-link-hover); }
a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1px dotted var(--color-text-secondary); }
.label { font-size: 1.5rem; color: #9ca3af; margin-top: 1rem; }
.footer {
margin: 2rem auto 0;
font-size: 0.9rem;
color: #9ca3af;
}
.debug {
font-size: 0.8rem;
color: #9ca3af;
margin: 1rem auto 0;
}
.debug-link { color: #9ca3af; border-bottom: 1px dotted #9ca3af; cursor: pointer; }
.debug-link:hover { color: #e5e7eb; }
a { color: #9ca3af; text-decoration: none; border-bottom: 1px dotted #9ca3af; }
.pulse { animation: pulse 0.5s ease-in-out; }
@keyframes pulse {
0% { transform: scale(1); }
@@ -56,6 +74,23 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
width: 90%;
position: relative;
}
.modal-content.map-content {
max-width: 1000px;
width: 100%;
height: 500px;
max-height: 80vh;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
#map {
width: 100%;
height: 100%;
background: #222;
}
.modal-title {
font-size: 0.9rem;
color: var(--color-modal-title);
@@ -63,6 +98,7 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
text-transform: uppercase;
letter-spacing: 1px;
}
.close-btn {
position: absolute;
top: 1.5rem;
@@ -75,6 +111,12 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
transition: color 0.2s;
}
.close-btn:hover { color: var(--color-modal-close-btn-hover); }
color: #9ca3af;
font-size: 1.2rem;
cursor: pointer;
z-index: 1001;
}
.close-btn:hover { color: #fff; }
.stat-row {
display: flex;
justify-content: space-between;
@@ -118,4 +160,140 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p
.theme-btn:hover .material-symbols-outlined {
color: var(--color-theme-toggle-hover);
.terminal {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 800px;
max-width: 100%;
height: 250px;
background: rgba(0, 0, 0, 0.9);
border: 1px solid #333;
border-bottom: none;
border-radius: 8px 8px 0 0;
z-index: 100;
font-family: "Courier New", Courier, monospace;
display: flex;
flex-direction: column;
padding: 12px;
color: #4ade80;
font-size: 12px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease;
}
.hidden {
display: none !important;
}
.terminal.hidden {
display: none;
}
.terminal.collapsed {
transform: translateX(-50%) translateY(100%);
}
.terminal-toggle {
position: absolute;
top: -24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
border: 1px solid #333;
border-bottom: none;
border-radius: 8px 8px 0 0;
color: #4ade80;
cursor: pointer;
font-family: monospace;
font-weight: bold;
font-size: 14px;
z-index: 101;
padding: 2px 15px;
height: 24px;
line-height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.terminal-toggle:hover {
color: #fff;
background: #222;
}
.system-status-bar {
height: 20px;
margin-bottom: 5px;
color: #666;
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.terminal-output {
flex: 1;
overflow-y: auto;
margin-bottom: 5px;
display: flex;
flex-direction: column;
gap: 2px;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.terminal-output::-webkit-scrollbar {
width: 6px;
}
.terminal-output::-webkit-scrollbar-track {
background: transparent;
}
.terminal-output::-webkit-scrollbar-thumb {
background-color: #333;
border-radius: 3px;
}
.terminal-output::-webkit-scrollbar-thumb:hover {
background-color: #444;
}
.terminal-input-line {
display: flex;
align-items: center;
border-top: 1px solid #333;
padding-top: 10px;
}
.prompt {
margin-right: 10px;
color: #4ade80;
}
#terminal-input {
flex: 1;
background: transparent;
border: none;
color: #fff;
font-family: inherit;
font-size: inherit;
outline: none;
}
.msg-system {
color: #666;
font-style: italic;
}
.msg-sender {
font-weight: bold;
}
.msg-content {
color: #ddd;
}
+17 -3
View File
@@ -8,7 +8,7 @@ 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");
const { DIAGNOSTICS_INTERVAL, ENABLE_CHAT, ENABLE_MAP } = require("./src/config/constants");
const main = async () => {
const identity = generateIdentity();
@@ -25,14 +25,27 @@ const main = async () => {
direct: swarmManager.getSwarm().connections.size,
id: identity.id,
diagnostics: diagnostics.getStats(),
chatEnabled: ENABLE_CHAT,
mapEnabled: ENABLE_MAP,
peers: peerManager.getPeersWithIps()
});
};
const chatCallback = (msg) => {
sseManager.broadcast(msg);
};
const chatSystemFn = (msg) => {
sseManager.broadcast(msg);
};
const messageHandler = new MessageHandler(
peerManager,
diagnostics,
(msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics),
broadcastUpdate
broadcastUpdate,
chatCallback,
chatSystemFn
);
const swarmManager = new SwarmManager(
@@ -41,7 +54,8 @@ const main = async () => {
diagnostics,
messageHandler,
(msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics),
broadcastUpdate
broadcastUpdate,
chatSystemFn
);
await swarmManager.start();
+36 -24
View File
@@ -1,42 +1,54 @@
const crypto = require("crypto");
const TOPIC_NAME = "hypermind-lklynet-v1";
const TOPIC_NAME = process.env.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.
*
* ----
* ricardoboss: added a way to get the best of both worlds: newer nodes will use a harder POW, making them compatible
* with others who use a harder POW while still being able to accept "old" POWs from clients
*/
const POW_PREFIX = "0000";
const MY_POW_PREFIX = "00000";
const VERIFICATION_POW_PREFIX = "0000";
const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 1000000;
const MAX_MESSAGE_SIZE = 2048;
const MAX_RELAY_HOPS = 2;
const MAX_CONNECTIONS = 32;
const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 50000;
const MAX_MESSAGE_SIZE = parseInt(process.env.MAX_MESSAGE_SIZE) || 2048;
const MAX_RELAY_HOPS = parseInt(process.env.MAX_RELAY_HOPS) || 2;
const MAX_CONNECTIONS = parseInt(process.env.MAX_CONNECTIONS) || 15;
const HEARTBEAT_INTERVAL = 5000;
const CONNECTION_ROTATION_INTERVAL = 30000;
const PEER_TIMEOUT = 15000;
const HEARTBEAT_INTERVAL = parseInt(process.env.HEARTBEAT_INTERVAL) || 30000;
const CONNECTION_ROTATION_INTERVAL = parseInt(process.env.CONNECTION_ROTATION_INTERVAL) || 300000;
const PEER_TIMEOUT = parseInt(process.env.PEER_TIMEOUT) || 45000;
const BROADCAST_THROTTLE = 1000;
const DIAGNOSTICS_INTERVAL = 10000;
const PORT = process.env.PORT || 3000;
const ENABLE_CHAT = process.env.ENABLE_CHAT === "true";
const ENABLE_MAP = process.env.ENABLE_MAP === 'true';
const CHAT_RATE_LIMIT = parseInt(process.env.CHAT_RATE_LIMIT) || 5000;
const VISUAL_LIMIT = parseInt(process.env.VISUAL_LIMIT) || 500;
module.exports = {
TOPIC_NAME,
TOPIC,
POW_PREFIX,
MAX_PEERS,
MAX_MESSAGE_SIZE,
MAX_RELAY_HOPS,
MAX_CONNECTIONS,
HEARTBEAT_INTERVAL,
CONNECTION_ROTATION_INTERVAL,
PEER_TIMEOUT,
BROADCAST_THROTTLE,
DIAGNOSTICS_INTERVAL,
PORT,
TOPIC_NAME,
TOPIC,
MY_POW_PREFIX,
VERIFICATION_POW_PREFIX,
MAX_PEERS,
MAX_MESSAGE_SIZE,
MAX_RELAY_HOPS,
MAX_CONNECTIONS,
HEARTBEAT_INTERVAL,
CONNECTION_ROTATION_INTERVAL,
PEER_TIMEOUT,
BROADCAST_THROTTLE,
DIAGNOSTICS_INTERVAL,
PORT,
ENABLE_CHAT,
ENABLE_MAP,
CHAT_RATE_LIMIT,
VISUAL_LIMIT,
};
+2 -2
View File
@@ -1,5 +1,5 @@
const crypto = require("crypto");
const { POW_PREFIX } = require("../config/constants");
const { MY_POW_PREFIX } = require("../config/constants");
const generateIdentity = () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
@@ -11,7 +11,7 @@ const generateIdentity = () => {
.createHash("sha256")
.update(id + nonce)
.digest("hex");
if (hash.startsWith(POW_PREFIX)) break;
if (hash.startsWith(MY_POW_PREFIX)) break;
nonce++;
}
+2 -2
View File
@@ -1,5 +1,5 @@
const crypto = require("crypto");
const { POW_PREFIX } = require("../config/constants");
const { VERIFICATION_POW_PREFIX } = require("../config/constants");
const verifyPoW = (id, nonce) => {
if (!nonce) return false;
@@ -7,7 +7,7 @@ const verifyPoW = (id, nonce) => {
.createHash("sha256")
.update(id + nonce)
.digest("hex");
return powHash.startsWith(POW_PREFIX);
return powHash.startsWith(VERIFICATION_POW_PREFIX);
}
const signMessage = (message, privateKey) => {
+221 -103
View File
@@ -1,133 +1,251 @@
const { verifyPoW, verifySignature, createPublicKey } = require("../core/security");
const { MAX_RELAY_HOPS } = require("../config/constants");
const {
verifyPoW,
verifySignature,
createPublicKey,
} = require("../core/security");
const { MAX_RELAY_HOPS, ENABLE_CHAT, CHAT_RATE_LIMIT } = 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();
constructor(
peerManager,
diagnostics,
relayCallback,
broadcastCallback,
chatCallback,
chatSystemFn
) {
this.peerManager = peerManager;
this.diagnostics = diagnostics;
this.relayCallback = relayCallback;
this.broadcastCallback = broadcastCallback;
this.chatCallback = chatCallback;
this.chatSystemFn = chatSystemFn;
this.bloomFilter = new BloomFilterManager();
this.bloomFilter.start();
this.chatRateLimits = new Map();
}
handleMessage(msg, sourceSocket) {
if (!validateMessage(msg)) {
return;
}
handleMessage(msg, sourceSocket) {
if (!validateMessage(msg)) {
return;
}
if (msg.type === "HEARTBEAT") {
this.handleHeartbeat(msg, sourceSocket);
} else if (msg.type === "LEAVE") {
this.handleLeave(msg, sourceSocket);
} else if (msg.type === "CHAT") {
this.handleChat(msg, sourceSocket);
}
}
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;
// Optimization: Check for duplicates BEFORE verifyPoW (CPU intensive)
const stored = this.peerManager.getPeer(id);
if (stored && seq <= stored.seq) {
this.diagnostics.increment("duplicateSeq");
return;
}
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 {
// Check if we can accept new peers (only matters for new peers)
if (!stored && !this.peerManager.canAcceptPeer(id)) return;
// Derive public key on-demand from peer ID
const 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);
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;
}
if (!verifyPoW(id, nonce)) {
this.diagnostics.increment("invalidPoW");
return;
}
handleLeave(msg, sourceSocket) {
this.diagnostics.increment("leaveMessages");
const { id, hops, sig } = msg;
if (!sig) return;
if (!sig) return;
try {
// Check if we can accept new peers (only matters for new peers)
if (!stored && !this.peerManager.canAcceptPeer(id)) return;
// Only process leave messages for peers we know about
if (!this.peerManager.hasPeer(id)) return;
// Derive public key on-demand from peer ID
const key = createPublicKey(id);
// Derive public key on-demand from peer ID
const key = createPublicKey(id);
if (!verifySignature(`seq:${seq}`, sig, key)) {
this.diagnostics.increment("invalidSig");
return;
}
if (!verifySignature(`type:LEAVE:${id}`, sig, key)) {
if (hops === 0) {
sourceSocket.peerId = id;
}
const getIp = (sock) => {
if (sock.remoteAddress) return sock.remoteAddress;
if (sock.rawStream && sock.rawStream.remoteHost)
return sock.rawStream.remoteHost;
if (sock.rawStream && sock.rawStream.remoteAddress)
return sock.rawStream.remoteAddress;
return null;
};
const ip = hops === 0 ? getIp(sourceSocket) : null;
const wasNew = this.peerManager.addOrUpdatePeer(id, seq, ip);
if (wasNew) {
this.diagnostics.increment("newPeersAdded");
this.broadcastCallback();
if (ENABLE_CHAT && this.chatSystemFn && hops === 0) {
this.chatSystemFn({
type: "SYSTEM",
content: `Connection established with Node ...${id.slice(-8)}`,
timestamp: Date.now(),
});
}
}
// 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;
// Only process leave messages for peers we know about
if (!this.peerManager.hasPeer(id)) return;
// Derive public key on-demand from peer ID
const key = createPublicKey(id);
if (!verifySignature(`type:LEAVE:${id}`, sig, key)) {
this.diagnostics.increment("invalidSig");
return;
}
if (this.peerManager.hasPeer(id)) {
this.peerManager.removePeer(id);
this.broadcastCallback();
if (ENABLE_CHAT && this.chatSystemFn && hops === 0) {
this.chatSystemFn({
type: "SYSTEM",
content: `Node ...${id.slice(-8)} disconnected.`,
timestamp: Date.now(),
});
}
// 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);
}
}
}
handleChat(msg, sourceSocket) {
const { scope, sender, id, sig, hops } = msg;
// Rate Limiting (apply to all chat messages)
const now = Date.now();
let rateData = this.chatRateLimits.get(sender);
if (!rateData || now - rateData.windowStart > 10000) {
// Reset window
rateData = { count: 0, windowStart: now };
}
if (rateData.count >= 5) {
return; // Drop message
}
if (!scope || scope === 'LOCAL') {
// Identity Verification: Ensure the sender matches the authenticated socket
if (!sourceSocket.peerId || sourceSocket.peerId !== sender) {
return;
}
rateData.count++;
this.chatRateLimits.set(sender, rateData);
if (this.chatCallback) {
this.chatCallback(msg);
}
} else if (scope === 'GLOBAL') {
if (!sig || !id) return;
// Check signature
const key = createPublicKey(sender);
if (!verifySignature(`chat:${id}`, sig, key)) {
this.diagnostics.increment("invalidSig");
return;
}
if (this.peerManager.hasPeer(id)) {
this.peerManager.removePeer(id);
this.broadcastCallback();
// Deduplication
if (this.bloomFilter.hasRelayed(id, "chat")) {
return;
}
this.bloomFilter.markRelayed(id, "chat");
// 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);
}
rateData.count++;
this.chatRateLimits.set(sender, rateData);
if (this.chatCallback) {
this.chatCallback(msg);
}
// Relay
if (hops < MAX_RELAY_HOPS) {
this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket);
}
}
}
}
const validateMessage = (msg) => {
if (!msg || typeof msg !== 'object') return false;
if (!msg.type) return false;
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;
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 === "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;
}
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;
}
if (msg.type === "CHAT") {
const allowedFields = ['type', 'sender', 'content', 'timestamp', 'scope', 'id', 'sig', 'hops'];
const fields = Object.keys(msg);
return fields.every(f => allowedFields.includes(f)) &&
msg.sender &&
msg.content && typeof msg.content === 'string' && msg.content.length <= 140 &&
typeof msg.timestamp === 'number';
}
return false;
};
module.exports = { MessageHandler, validateMessage };
+27 -10
View File
@@ -1,16 +1,33 @@
const relayMessage = (msg, sourceSocket, swarm, diagnostics) => {
const data = JSON.stringify(msg) + "\n";
const relayCount = swarm.connections.size - 1;
const data = JSON.stringify(msg) + "\n";
if (diagnostics) {
diagnostics.increment("bytesRelayed", data.length * relayCount);
}
// Gossip Subsampling:
// Instead of flooding everyone (which causes massive bandwidth usage with 50 connections),
// we relay to a random subset of peers (e.g., 6).
// This maintains "Epidemic" reach (O(log N)) while capping bandwidth.
for (const socket of swarm.connections) {
if (socket !== sourceSocket) {
socket.write(data);
}
const TARGET_GOSSIP_COUNT = 6;
const allSockets = Array.from(swarm.connections);
const eligible = allSockets.filter((s) => s !== sourceSocket);
let targets = eligible;
if (eligible.length > TARGET_GOSSIP_COUNT) {
// Fisher-Yates shuffle (partial) to pick random peers
for (let i = eligible.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[eligible[i], eligible[j]] = [eligible[j], eligible[i]];
}
}
targets = eligible.slice(0, TARGET_GOSSIP_COUNT);
}
if (diagnostics) {
diagnostics.increment("bytesRelayed", data.length * targets.length);
}
for (const socket of targets) {
socket.write(data);
}
};
module.exports = { relayMessage };
+18 -2
View File
@@ -1,15 +1,16 @@
const Hyperswarm = require("hyperswarm");
const { signMessage } = require("../core/security");
const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL, MAX_CONNECTIONS, CONNECTION_ROTATION_INTERVAL } = require("../config/constants");
const { TOPIC, TOPIC_NAME, HEARTBEAT_INTERVAL, MAX_CONNECTIONS, CONNECTION_ROTATION_INTERVAL, ENABLE_CHAT } = require("../config/constants");
class SwarmManager {
constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn) {
constructor(identity, peerManager, diagnostics, messageHandler, relayFn, broadcastFn, chatSystemFn) {
this.identity = identity;
this.peerManager = peerManager;
this.diagnostics = diagnostics;
this.messageHandler = messageHandler;
this.relayFn = relayFn;
this.broadcastFn = broadcastFn;
this.chatSystemFn = chatSystemFn;
this.swarm = new Hyperswarm();
this.heartbeatInterval = null;
@@ -109,6 +110,13 @@ class SwarmManager {
}
if (oldest) {
if (ENABLE_CHAT && this.chatSystemFn && oldest.peerId) {
this.chatSystemFn({
type: "SYSTEM",
content: `Connection with Node ...${oldest.peerId.slice(-8)} severed (Rotation).`,
timestamp: Date.now()
});
}
oldest.destroy();
}
}, CONNECTION_ROTATION_INTERVAL);
@@ -143,6 +151,14 @@ class SwarmManager {
getSwarm() {
return this.swarm;
}
broadcastChat(msg) {
if (!ENABLE_CHAT) return;
const msgStr = JSON.stringify(msg) + "\n";
for (const socket of this.swarm.connections) {
socket.write(msgStr);
}
}
}
module.exports = { SwarmManager };
+1 -1
View File
@@ -3,7 +3,7 @@
* Prevents re-relaying messages we've already seen
*/
class BloomFilter {
constructor(size = 10000, hashCount = 3) {
constructor(size = 200000, hashCount = 3) {
this.size = size;
this.hashCount = hashCount;
this.bits = new Uint8Array(Math.ceil(size / 8));
+17 -1
View File
@@ -9,7 +9,7 @@ class PeerManager {
this.mySeq = 0;
}
addOrUpdatePeer(id, seq) {
addOrUpdatePeer(id, seq, ip = null) {
const stored = this.seenPeers.get(id);
const wasNew = !stored;
@@ -19,6 +19,7 @@ class PeerManager {
this.seenPeers.set(id, {
seq,
lastSeen: Date.now(),
ip: ip || (stored ? stored.ip : null),
});
return wasNew;
@@ -49,6 +50,11 @@ class PeerManager {
if (now - data.lastSeen > PEER_TIMEOUT) {
this.seenPeers.delete(id);
removed++;
} else {
// Optimization: Since LRUCache maintains insertion order (updated on access),
// the Map is sorted by lastSeen (ascending).
// If we find a non-stale peer, all subsequent peers are also non-stale.
break;
}
}
@@ -70,6 +76,16 @@ class PeerManager {
getSeq() {
return this.mySeq;
}
getPeersWithIps() {
const peers = [];
for (const [id, data] of this.seenPeers.entries()) {
if (data.ip) {
peers.push({ id, ip: data.ip });
}
}
return peers;
}
}
module.exports = { PeerManager };
+64 -2
View File
@@ -1,6 +1,9 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { signMessage } = require("../core/security");
const { ENABLE_CHAT, ENABLE_MAP, CHAT_RATE_LIMIT, VISUAL_LIMIT } = require("../config/constants");
const HTML_TEMPLATE = fs.readFileSync(
path.join(__dirname, "../../public/index.html"),
@@ -8,14 +11,18 @@ const HTML_TEMPLATE = fs.readFileSync(
);
const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) => {
app.use(express.json());
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);
.replace(/\{\{ID\}\}/g, "..." + identity.id.slice(-8))
.replace(/\{\{DIRECT\}\}/g, directPeers)
.replace(/\{\{MAP_CLASS\}\}/g, ENABLE_MAP ? '' : 'hidden')
.replace(/\{\{VISUAL_LIMIT\}\}/g, VISUAL_LIMIT);
res.send(html);
});
@@ -34,6 +41,8 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics)
direct: swarm.getSwarm().connections.size,
id: identity.id,
diagnostics: diagnostics.getStats(),
chatEnabled: ENABLE_CHAT,
peers: peerManager.getPeersWithIps()
});
res.write(`data: ${data}\n\n`);
@@ -49,9 +58,62 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics)
direct: swarm.getSwarm().connections.size,
id: identity.id,
diagnostics: diagnostics.getStats(),
chatEnabled: ENABLE_CHAT,
peers: peerManager.getPeersWithIps()
});
});
let chatHistory = []; // Store timestamps of recent messages
app.post("/api/chat", (req, res) => {
if (!ENABLE_CHAT) {
return res.status(403).json({ error: "Chat disabled" });
}
const now = Date.now();
// Clean up old timestamps (older than CHAT_RATE_LIMIT)
chatHistory = chatHistory.filter(time => now - time < CHAT_RATE_LIMIT);
if (chatHistory.length >= 5) {
return res.status(429).json({ error: `Rate limit exceeded: Max 5 messages per ${CHAT_RATE_LIMIT / 1000} seconds` });
}
chatHistory.push(now);
const { content, scope = 'LOCAL' } = req.body;
if (!content || typeof content !== 'string' || content.length > 140) {
return res.status(400).json({ error: "Invalid content" });
}
if (scope !== 'LOCAL' && scope !== 'GLOBAL') {
return res.status(400).json({ error: "Invalid scope" });
}
const timestamp = Date.now();
// Create a unique ID that depends on content to prevent replay/duplicates
const idBase = identity.id + content + timestamp;
const msgId = crypto.createHash('sha256').update(idBase).digest('hex');
const msg = {
type: "CHAT",
id: msgId,
sender: identity.id,
content: content,
timestamp: timestamp,
scope: scope,
hops: 0
};
if (scope === 'GLOBAL') {
msg.sig = signMessage(`chat:${msgId}`, identity.privateKey);
}
swarm.broadcastChat(msg);
sseManager.broadcast(msg);
res.json({ success: true });
});
app.use(express.static(path.join(__dirname, "../../public")));
}
+4
View File
@@ -19,6 +19,10 @@ class SSEManager {
if (now - this.lastBroadcast < BROADCAST_THROTTLE) return;
this.lastBroadcast = now;
this.broadcast(data);
}
broadcast(data) {
const message = JSON.stringify(data);
for (const client of this.clients) {
client.write(`data: ${message}\n\n`);