feat: add peer listing and graceful shutdown

- Add `/who` command to list active users via new `/api/peers` endpoint
- Implement graceful shutdown for server and swarm with proper cleanup
- Enhance SSE broadcast with client connection state checks
- Expose peer list in stats and add health endpoint
- Fix theme color refresh for network visualization
This commit is contained in:
lklynet
2026-03-04 19:07:53 -05:00
parent 3b80cb9036
commit 7303f605ac
11 changed files with 135 additions and 19 deletions
+57 -4
View File
@@ -4,6 +4,8 @@ const totalUniqueEl = document.getElementById("total-unique");
const canvas = document.getElementById("network");
const ctx = canvas.getContext("2d");
let particles = [];
let particleColor = "#4ade80";
let particleLinkColor = "#22d3ee";
function getThemeColor(varName) {
return getComputedStyle(document.documentElement)
@@ -11,6 +13,11 @@ function getThemeColor(varName) {
.trim();
}
const refreshThemeColors = () => {
particleColor = getThemeColor("--color-particle");
particleLinkColor = getThemeColor("--color-particle-link");
};
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
@@ -39,7 +46,7 @@ class Particle {
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fillStyle = getThemeColor("--color-particle");
ctx.fillStyle = particleColor;
ctx.fill();
}
}
@@ -62,15 +69,15 @@ const updateParticles = (count) => {
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = getThemeColor("--color-particle-link");
ctx.strokeStyle = particleLinkColor;
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);
const distanceSquared = dx * dx + dy * dy;
if (distance < 150) {
if (distanceSquared < 22500) {
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
@@ -473,6 +480,17 @@ const getColorFromId = (id) => {
const seenMessageIds = new Set();
const messageIdHistory = [];
const appendSystemLines = (lines) => {
const entries = Array.isArray(lines) ? lines : [lines];
for (const line of entries) {
const div = document.createElement("div");
div.className = "system-message";
div.innerText = `[SYSTEM] ${line}`;
terminalOutput.appendChild(div);
}
terminalOutput.scrollTop = terminalOutput.scrollHeight;
};
const appendMessage = (msg) => {
const div = document.createElement("div");
@@ -631,6 +649,39 @@ terminalInput.addEventListener("keypress", async (e) => {
}
}
return;
} else if (content === "/who") {
try {
const res = await fetch("/api/peers");
if (!res.ok) {
systemStatusBar.innerText = "[SYSTEM] Failed to fetch peer list";
return;
}
const data = await res.json();
const peers = Array.isArray(data.peers) ? data.peers : [];
const mappedPeers = peers.map((peer) => {
if (peer.id && peer.screenname) {
nameToId.set(peer.screenname, peer.id);
}
const shortId = peer.id ? peer.id.slice(-8) : "unknown";
return `${peer.screenname || "Unknown"} (...${shortId})`;
});
const visiblePeers = mappedPeers.slice(0, 20);
const outputLines = [
`Active users: ${mappedPeers.length}`,
...visiblePeers,
];
if (mappedPeers.length > visiblePeers.length) {
outputLines.push(`...and ${mappedPeers.length - visiblePeers.length} more`);
}
appendSystemLines(outputLines);
systemStatusBar.innerText = `[SYSTEM] Listed ${mappedPeers.length} active users`;
} catch (err) {
systemStatusBar.innerText = "[SYSTEM] Failed to fetch peer list";
}
return;
} else if (content.startsWith("/whisper ")) {
const parts = content.split(" ");
if (parts.length < 3) {
@@ -840,6 +891,7 @@ const initialCount = parseInt(countEl.dataset.initialCount) || 0;
countEl.innerText = initialCount;
countEl.classList.add("loaded");
updateParticles(initialCount);
refreshThemeColors();
animate();
const bandwidthHistory = { timestamps: [], bytesIn: [], bytesOut: [] };
@@ -1040,6 +1092,7 @@ function cycleTheme() {
if (oldLink) oldLink.remove();
newLink.id = "theme-css";
localStorage.setItem("hypermind-theme", newTheme);
refreshThemeColors();
btn.disabled = false;
btn.style.opacity = "";
showThemeNotification(newTheme);
+1
View File
@@ -40,6 +40,7 @@ export const helpCommand = {
},
{ cmd: "/block &lt;user&gt;", desc: "Block messages from a user" },
{ cmd: "/unblock &lt;user&gt;", desc: "Unblock a user" },
{ cmd: "/who", desc: "List active users in the swarm" },
{
cmd: "/local &lt;msg&gt;",
desc: "Send message to direct peers only (Global by default)",
+12 -4
View File
@@ -65,16 +65,24 @@ const main = async () => {
() => swarmManager.getSwarm().connections.size
);
setInterval(() => {
const diagnosticsTimer = setInterval(() => {
broadcastUpdate();
}, DIAGNOSTICS_INTERVAL);
diagnosticsTimer.unref();
const app = createServer(identity, peerManager, swarmManager, sseManager, diagnostics);
startServer(app, identity);
const webServer = startServer(app, identity);
const handleShutdown = () => {
let shuttingDown = false;
const handleShutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
clearInterval(diagnosticsTimer);
diagnostics.stopLogging();
swarmManager.shutdown();
await swarmManager.shutdown();
webServer.close(() => {
process.exit(0);
});
};
process.on("SIGINT", handleShutdown);
-1
View File
@@ -7,7 +7,6 @@ const crypto = require("crypto");
const {
MAX_RELAY_HOPS,
ENABLE_CHAT,
CHAT_RATE_LIMIT,
} = require("../config/constants");
const { BloomFilterManager } = require("../state/bloom");
const { generateScreenname } = require("../utils/name-generator");
+5 -7
View File
@@ -2,13 +2,11 @@ const Hyperswarm = require("hyperswarm");
const { signMessage } = require("../core/security");
const {
TOPIC,
TOPIC_NAME,
HEARTBEAT_INTERVAL,
MAX_CONNECTIONS,
CONNECTION_ROTATION_INTERVAL,
ENABLE_CHAT,
} = require("../config/constants");
const { generateScreenname } = require("../utils/name-generator");
class SwarmManager {
constructor(
@@ -122,6 +120,7 @@ class SwarmManager {
this.broadcastFn();
}
}, HEARTBEAT_INTERVAL);
this.heartbeatInterval.unref();
}
startRotation() {
@@ -148,9 +147,10 @@ class SwarmManager {
oldest.destroy();
}
}, CONNECTION_ROTATION_INTERVAL);
this.rotationInterval.unref();
}
shutdown() {
async shutdown() {
const sig = signMessage(
`type:LEAVE:${this.identity.id}`,
this.identity.privateKey
@@ -176,10 +176,8 @@ class SwarmManager {
if (this.rotationInterval) {
clearInterval(this.rotationInterval);
}
setTimeout(() => {
process.exit(0);
}, 500);
this.messageHandler.bloomFilter.stop();
await this.swarm.destroy();
}
getSwarm() {
+13
View File
@@ -92,6 +92,19 @@ class PeerManager {
}
return peers;
}
getPeerList() {
const peers = [];
for (const [id, data] of this.seenPeers.entries()) {
peers.push({
id,
seq: data.seq,
lastSeen: data.lastSeen,
ip: data.ip || null,
});
}
return peers;
}
}
module.exports = { PeerManager };
+1
View File
@@ -48,6 +48,7 @@ const setupRoutes = (
peerManager,
swarm,
diagnostics,
sseManager,
};
const chatDeps = {
+31 -1
View File
@@ -1,4 +1,5 @@
const { ENABLE_CHAT } = require("../../config/constants");
const { ENABLE_CHAT, ENABLE_MAP } = require("../../config/constants");
const { generateScreenname } = require("../../utils/name-generator");
const setupStatsRoutes = (router, dependencies) => {
const { peerManager, swarm, diagnostics } = dependencies;
@@ -12,9 +13,38 @@ const setupStatsRoutes = (router, dependencies) => {
screenname: dependencies.identity.screenname,
diagnostics: diagnostics.getStats(),
chatEnabled: ENABLE_CHAT,
mapEnabled: ENABLE_MAP,
peers: peerManager.getPeersWithIps(),
});
});
router.get("/api/health", (req, res) => {
res.json({
status: "ok",
uptime: process.uptime(),
peers: peerManager.size,
direct: swarm.getSwarm().connections.size,
sseClients: dependencies.sseManager ? dependencies.sseManager.size : 0,
timestamp: Date.now(),
});
});
router.get("/api/peers", (req, res) => {
const peers = peerManager
.getPeerList()
.sort((a, b) => b.lastSeen - a.lastSeen)
.map((peer) => ({
id: peer.id,
screenname: generateScreenname(peer.id),
lastSeen: peer.lastSeen,
ip: peer.ip,
}));
res.json({
count: peers.length,
peers,
});
});
};
module.exports = { setupStatsRoutes };
+2 -1
View File
@@ -11,10 +11,11 @@ const createServer = (identity, peerManager, swarm, sseManager, diagnostics) =>
}
const startServer = (app, identity) => {
app.listen(PORT, () => {
const server = app.listen(PORT, () => {
console.log(`Hypermind Node running on port ${PORT}`);
console.log(`ID: ${identity.id}`);
});
return server;
}
module.exports = { createServer, startServer };
+9 -1
View File
@@ -25,7 +25,15 @@ class SSEManager {
broadcast(data) {
const message = JSON.stringify(data);
for (const client of this.clients) {
client.write(`data: ${message}\n\n`);
if (client.writableEnded || client.destroyed) {
this.clients.delete(client);
continue;
}
try {
client.write(`data: ${message}\n\n`);
} catch (e) {
this.clients.delete(client);
}
}
}
+4
View File
@@ -72,6 +72,10 @@ const test = (method, path, body = null, validate, timeout = 5000) => {
const json = JSON.parse(data);
return json.count !== undefined && json.id && json.screenname && json.diagnostics;
}),
test("GET", "/api/peers", null, (data) => {
const json = JSON.parse(data);
return typeof json.count === "number" && Array.isArray(json.peers);
}),
test("GET", "/api/github/latest-release", null, (data) => {
const json = JSON.parse(data);
return json.tag_name && json.html_url;