feat: implement ephemeral p2p chat with rate limiting and terminal UI

This commit is contained in:
lklynet
2026-01-03 00:24:13 -05:00
parent a37f282c23
commit 5be3659b57
10 changed files with 360 additions and 8 deletions
+16
View File
@@ -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
View File
@@ -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) {
+7
View File
@@ -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">&gt;</span>
<input type="text" id="terminal-input" maxlength="140" placeholder="Broadcast to direct peers..." autocomplete="off">
</div>
</div>
<script src="/app.js"></script>
</body>
+94
View File
@@ -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;
}
+15 -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 } = 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();
+4
View File
@@ -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
View File
@@ -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
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 };
+41 -1
View File
@@ -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")));
}
+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`);