From 7303f605ac4f5bc6e5146b4d647cc30dc4bc2a35 Mon Sep 17 00:00:00 2001 From: lklynet Date: Wed, 4 Mar 2026 19:07:53 -0500 Subject: [PATCH] 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 --- public/app.js | 61 ++++++++++++++++++++++++++++++--- public/js/chat-commands/help.js | 1 + server.js | 16 ++++++--- src/p2p/messaging.js | 1 - src/p2p/swarm.js | 12 +++---- src/state/peers.js | 13 +++++++ src/web/routes.js | 1 + src/web/routes/stats.js | 32 ++++++++++++++++- src/web/server.js | 3 +- src/web/sse.js | 10 +++++- test-api.js | 4 +++ 11 files changed, 135 insertions(+), 19 deletions(-) diff --git a/public/app.js b/public/app.js index b22caaa..fc57017 100644 --- a/public/app.js +++ b/public/app.js @@ -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); diff --git a/public/js/chat-commands/help.js b/public/js/chat-commands/help.js index f9e74ac..d77ba0f 100644 --- a/public/js/chat-commands/help.js +++ b/public/js/chat-commands/help.js @@ -40,6 +40,7 @@ export const helpCommand = { }, { cmd: "/block <user>", desc: "Block messages from a user" }, { cmd: "/unblock <user>", desc: "Unblock a user" }, + { cmd: "/who", desc: "List active users in the swarm" }, { cmd: "/local <msg>", desc: "Send message to direct peers only (Global by default)", diff --git a/server.js b/server.js index 40c9757..da1a5a3 100644 --- a/server.js +++ b/server.js @@ -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); diff --git a/src/p2p/messaging.js b/src/p2p/messaging.js index 4e316fe..c5a458f 100644 --- a/src/p2p/messaging.js +++ b/src/p2p/messaging.js @@ -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"); diff --git a/src/p2p/swarm.js b/src/p2p/swarm.js index 9fbe592..b65d57c 100644 --- a/src/p2p/swarm.js +++ b/src/p2p/swarm.js @@ -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() { diff --git a/src/state/peers.js b/src/state/peers.js index 155de15..ccf1f05 100644 --- a/src/state/peers.js +++ b/src/state/peers.js @@ -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 }; diff --git a/src/web/routes.js b/src/web/routes.js index 0ec1ecb..1b68ba9 100644 --- a/src/web/routes.js +++ b/src/web/routes.js @@ -48,6 +48,7 @@ const setupRoutes = ( peerManager, swarm, diagnostics, + sseManager, }; const chatDeps = { diff --git a/src/web/routes/stats.js b/src/web/routes/stats.js index b5889a2..1f5ff0f 100644 --- a/src/web/routes/stats.js +++ b/src/web/routes/stats.js @@ -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 }; diff --git a/src/web/server.js b/src/web/server.js index 00a4409..eb206b3 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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 }; diff --git a/src/web/sse.js b/src/web/sse.js index 27e990e..d2c736d 100644 --- a/src/web/sse.js +++ b/src/web/sse.js @@ -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); + } } } diff --git a/test-api.js b/test-api.js index f9db9ec..6b90718 100644 --- a/test-api.js +++ b/test-api.js @@ -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;