mirror of
https://github.com/lklynet/hypermind.git
synced 2026-05-03 09:30:36 +00:00
feat: implement ephemeral p2p chat with rate limiting and terminal UI
This commit is contained in:
@@ -123,6 +123,22 @@ Add this to your `services.yaml`:
|
||||
| --- | --- | --- |
|
||||
| `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. |
|
||||
| `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:** You can only talk to your ~32 direct connections.
|
||||
* **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`.
|
||||
|
||||
## » Usage
|
||||
|
||||
|
||||
+103
@@ -99,11 +99,114 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
const terminal = document.getElementById('terminal');
|
||||
const terminalOutput = document.getElementById('terminal-output');
|
||||
const terminalInput = document.getElementById('terminal-input');
|
||||
const promptEl = document.querySelector('.prompt');
|
||||
let myId = null;
|
||||
let myChatHistory = [];
|
||||
|
||||
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 === 'SYSTEM') {
|
||||
div.className = 'msg-system';
|
||||
div.innerText = `[SYSTEM] ${msg.content}`;
|
||||
} else if (msg.type === 'CHAT') {
|
||||
const senderColor = getColorFromId(msg.sender);
|
||||
const senderName = msg.sender === myId ? 'You' : msg.sender.slice(-4);
|
||||
|
||||
const senderSpan = document.createElement('span');
|
||||
senderSpan.className = 'msg-sender';
|
||||
senderSpan.style.color = senderColor;
|
||||
senderSpan.innerText = `[${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') {
|
||||
const content = terminalInput.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
terminalInput.value = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const evtSource = new EventSource("/events");
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'CHAT' || data.type === 'SYSTEM') {
|
||||
appendMessage(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.chatEnabled) {
|
||||
terminal.classList.remove('hidden');
|
||||
document.body.classList.add('chat-active');
|
||||
} else {
|
||||
terminal.classList.add('hidden');
|
||||
document.body.classList.remove('chat-active');
|
||||
}
|
||||
|
||||
if (data.id) myId = data.id;
|
||||
|
||||
updateParticles(data.count);
|
||||
|
||||
if (countEl.innerText != data.count) {
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="terminal" class="terminal hidden">
|
||||
<div id="terminal-output" class="terminal-output"></div>
|
||||
<div class="terminal-input-line">
|
||||
<span class="prompt">></span>
|
||||
<input type="text" id="terminal-input" maxlength="140" placeholder="Broadcast to direct peers..." autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -9,6 +9,11 @@ body {
|
||||
background: #111;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
transition: padding-bottom 0.3s ease;
|
||||
}
|
||||
|
||||
body.chat-active {
|
||||
padding-bottom: 250px;
|
||||
}
|
||||
|
||||
.container { text-align: center; position: relative; z-index: 10; }
|
||||
@@ -85,3 +90,92 @@ a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; }
|
||||
color: #333;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.terminal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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 } = require("./src/config/constants");
|
||||
|
||||
const main = async () => {
|
||||
const identity = generateIdentity();
|
||||
@@ -24,14 +24,25 @@ const main = async () => {
|
||||
direct: swarmManager.getSwarm().connections.size,
|
||||
id: identity.id,
|
||||
diagnostics: diagnostics.getStats(),
|
||||
chatEnabled: ENABLE_CHAT,
|
||||
});
|
||||
};
|
||||
|
||||
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(
|
||||
@@ -40,7 +51,8 @@ const main = async () => {
|
||||
diagnostics,
|
||||
messageHandler,
|
||||
(msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics),
|
||||
broadcastUpdate
|
||||
broadcastUpdate,
|
||||
chatSystemFn
|
||||
);
|
||||
|
||||
await swarmManager.start();
|
||||
|
||||
@@ -24,6 +24,8 @@ const PEER_TIMEOUT = 15000;
|
||||
const BROADCAST_THROTTLE = 1000;
|
||||
const DIAGNOSTICS_INTERVAL = 10000;
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const ENABLE_CHAT = process.env.ENABLE_CHAT === 'true';
|
||||
const CHAT_RATE_LIMIT = 5000;
|
||||
|
||||
module.exports = {
|
||||
TOPIC_NAME,
|
||||
@@ -39,4 +41,6 @@ module.exports = {
|
||||
BROADCAST_THROTTLE,
|
||||
DIAGNOSTICS_INTERVAL,
|
||||
PORT,
|
||||
ENABLE_CHAT,
|
||||
CHAT_RATE_LIMIT,
|
||||
};
|
||||
|
||||
+58
-2
@@ -1,15 +1,18 @@
|
||||
const { verifyPoW, verifySignature, createPublicKey } = require("../core/security");
|
||||
const { MAX_RELAY_HOPS } = require("../config/constants");
|
||||
const { MAX_RELAY_HOPS, ENABLE_CHAT } = require("../config/constants");
|
||||
const { BloomFilterManager } = require("../state/bloom");
|
||||
|
||||
class MessageHandler {
|
||||
constructor(peerManager, diagnostics, relayCallback, broadcastCallback) {
|
||||
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) {
|
||||
@@ -21,6 +24,8 @@ class MessageHandler {
|
||||
this.handleHeartbeat(msg, sourceSocket);
|
||||
} else if (msg.type === "LEAVE") {
|
||||
this.handleLeave(msg, sourceSocket);
|
||||
} else if (msg.type === "CHAT") {
|
||||
this.handleChat(msg, sourceSocket);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +69,13 @@ class MessageHandler {
|
||||
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)
|
||||
@@ -95,6 +107,14 @@ class MessageHandler {
|
||||
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");
|
||||
@@ -102,6 +122,33 @@ class MessageHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleChat(msg, sourceSocket) {
|
||||
// Identity Verification: Ensure the sender matches the authenticated socket
|
||||
if (!sourceSocket.peerId || sourceSocket.peerId !== msg.sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate Limiting: Prevent flooding (5 messages per 10 seconds per peer)
|
||||
const now = Date.now();
|
||||
let rateData = this.chatRateLimits.get(msg.sender);
|
||||
|
||||
if (!rateData || now - rateData.windowStart > 10000) {
|
||||
// Reset window
|
||||
rateData = { count: 0, windowStart: now };
|
||||
}
|
||||
|
||||
if (rateData.count >= 5) {
|
||||
return; // Drop message
|
||||
}
|
||||
|
||||
rateData.count++;
|
||||
this.chatRateLimits.set(msg.sender, rateData);
|
||||
|
||||
if (this.chatCallback) {
|
||||
this.chatCallback(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validateMessage = (msg) => {
|
||||
@@ -126,6 +173,15 @@ const validateMessage = (msg) => {
|
||||
msg.id && typeof msg.hops === 'number' && msg.sig;
|
||||
}
|
||||
|
||||
if (msg.type === "CHAT") {
|
||||
const allowedFields = ['type', 'sender', 'content', 'timestamp'];
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -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 };
|
||||
|
||||
+41
-1
@@ -1,6 +1,7 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ENABLE_CHAT, CHAT_RATE_LIMIT } = require("../config/constants");
|
||||
|
||||
const HTML_TEMPLATE = fs.readFileSync(
|
||||
path.join(__dirname, "../../public/index.html"),
|
||||
@@ -8,13 +9,15 @@ 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(/\{\{ID\}\}/g, "..." + identity.id.slice(-8))
|
||||
.replace(/\{\{DIRECT\}\}/g, directPeers);
|
||||
|
||||
res.send(html);
|
||||
@@ -33,6 +36,7 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics)
|
||||
direct: swarm.getSwarm().connections.size,
|
||||
id: identity.id,
|
||||
diagnostics: diagnostics.getStats(),
|
||||
chatEnabled: ENABLE_CHAT
|
||||
});
|
||||
res.write(`data: ${data}\n\n`);
|
||||
|
||||
@@ -47,9 +51,45 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics)
|
||||
direct: swarm.getSwarm().connections.size,
|
||||
id: identity.id,
|
||||
diagnostics: diagnostics.getStats(),
|
||||
chatEnabled: ENABLE_CHAT
|
||||
});
|
||||
});
|
||||
|
||||
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 10 seconds)
|
||||
chatHistory = chatHistory.filter(time => now - time < 10000);
|
||||
|
||||
if (chatHistory.length >= 5) {
|
||||
return res.status(429).json({ error: "Rate limit exceeded: Max 5 messages per 10 seconds" });
|
||||
}
|
||||
|
||||
chatHistory.push(now);
|
||||
|
||||
const { content } = req.body;
|
||||
if (!content || typeof content !== 'string' || content.length > 140) {
|
||||
return res.status(400).json({ error: "Invalid content" });
|
||||
}
|
||||
|
||||
const msg = {
|
||||
type: "CHAT",
|
||||
sender: identity.id,
|
||||
content: content,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
swarm.broadcastChat(msg);
|
||||
sseManager.broadcast(msg);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, "../../public")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user