Skip to content

Commit 7303f60

Browse files
committed
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
1 parent 3b80cb9 commit 7303f60

File tree

11 files changed

+135
-19
lines changed

11 files changed

+135
-19
lines changed

public/app.js

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@ const totalUniqueEl = document.getElementById("total-unique");
44
const canvas = document.getElementById("network");
55
const ctx = canvas.getContext("2d");
66
let particles = [];
7+
let particleColor = "#4ade80";
8+
let particleLinkColor = "#22d3ee";
79

810
function getThemeColor(varName) {
911
return getComputedStyle(document.documentElement)
1012
.getPropertyValue(varName)
1113
.trim();
1214
}
1315

16+
const refreshThemeColors = () => {
17+
particleColor = getThemeColor("--color-particle");
18+
particleLinkColor = getThemeColor("--color-particle-link");
19+
};
20+
1421
function resize() {
1522
canvas.width = window.innerWidth;
1623
canvas.height = window.innerHeight;
@@ -39,7 +46,7 @@ class Particle {
3946
draw() {
4047
ctx.beginPath();
4148
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
42-
ctx.fillStyle = getThemeColor("--color-particle");
49+
ctx.fillStyle = particleColor;
4350
ctx.fill();
4451
}
4552
}
@@ -62,15 +69,15 @@ const updateParticles = (count) => {
6269
const animate = () => {
6370
ctx.clearRect(0, 0, canvas.width, canvas.height);
6471

65-
ctx.strokeStyle = getThemeColor("--color-particle-link");
72+
ctx.strokeStyle = particleLinkColor;
6673
ctx.lineWidth = 1;
6774
for (let i = 0; i < particles.length; i++) {
6875
for (let j = i + 1; j < particles.length; j++) {
6976
const dx = particles[i].x - particles[j].x;
7077
const dy = particles[i].y - particles[j].y;
71-
const distance = Math.sqrt(dx * dx + dy * dy);
78+
const distanceSquared = dx * dx + dy * dy;
7279

73-
if (distance < 150) {
80+
if (distanceSquared < 22500) {
7481
ctx.beginPath();
7582
ctx.moveTo(particles[i].x, particles[i].y);
7683
ctx.lineTo(particles[j].x, particles[j].y);
@@ -473,6 +480,17 @@ const getColorFromId = (id) => {
473480
const seenMessageIds = new Set();
474481
const messageIdHistory = [];
475482

483+
const appendSystemLines = (lines) => {
484+
const entries = Array.isArray(lines) ? lines : [lines];
485+
for (const line of entries) {
486+
const div = document.createElement("div");
487+
div.className = "system-message";
488+
div.innerText = `[SYSTEM] ${line}`;
489+
terminalOutput.appendChild(div);
490+
}
491+
terminalOutput.scrollTop = terminalOutput.scrollHeight;
492+
};
493+
476494
const appendMessage = (msg) => {
477495
const div = document.createElement("div");
478496

@@ -631,6 +649,39 @@ terminalInput.addEventListener("keypress", async (e) => {
631649
}
632650
}
633651
return;
652+
} else if (content === "/who") {
653+
try {
654+
const res = await fetch("/api/peers");
655+
if (!res.ok) {
656+
systemStatusBar.innerText = "[SYSTEM] Failed to fetch peer list";
657+
return;
658+
}
659+
660+
const data = await res.json();
661+
const peers = Array.isArray(data.peers) ? data.peers : [];
662+
const mappedPeers = peers.map((peer) => {
663+
if (peer.id && peer.screenname) {
664+
nameToId.set(peer.screenname, peer.id);
665+
}
666+
const shortId = peer.id ? peer.id.slice(-8) : "unknown";
667+
return `${peer.screenname || "Unknown"} (...${shortId})`;
668+
});
669+
670+
const visiblePeers = mappedPeers.slice(0, 20);
671+
const outputLines = [
672+
`Active users: ${mappedPeers.length}`,
673+
...visiblePeers,
674+
];
675+
if (mappedPeers.length > visiblePeers.length) {
676+
outputLines.push(`...and ${mappedPeers.length - visiblePeers.length} more`);
677+
}
678+
679+
appendSystemLines(outputLines);
680+
systemStatusBar.innerText = `[SYSTEM] Listed ${mappedPeers.length} active users`;
681+
} catch (err) {
682+
systemStatusBar.innerText = "[SYSTEM] Failed to fetch peer list";
683+
}
684+
return;
634685
} else if (content.startsWith("/whisper ")) {
635686
const parts = content.split(" ");
636687
if (parts.length < 3) {
@@ -840,6 +891,7 @@ const initialCount = parseInt(countEl.dataset.initialCount) || 0;
840891
countEl.innerText = initialCount;
841892
countEl.classList.add("loaded");
842893
updateParticles(initialCount);
894+
refreshThemeColors();
843895
animate();
844896

845897
const bandwidthHistory = { timestamps: [], bytesIn: [], bytesOut: [] };
@@ -1040,6 +1092,7 @@ function cycleTheme() {
10401092
if (oldLink) oldLink.remove();
10411093
newLink.id = "theme-css";
10421094
localStorage.setItem("hypermind-theme", newTheme);
1095+
refreshThemeColors();
10431096
btn.disabled = false;
10441097
btn.style.opacity = "";
10451098
showThemeNotification(newTheme);

public/js/chat-commands/help.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const helpCommand = {
4040
},
4141
{ cmd: "/block &lt;user&gt;", desc: "Block messages from a user" },
4242
{ cmd: "/unblock &lt;user&gt;", desc: "Unblock a user" },
43+
{ cmd: "/who", desc: "List active users in the swarm" },
4344
{
4445
cmd: "/local &lt;msg&gt;",
4546
desc: "Send message to direct peers only (Global by default)",

server.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,24 @@ const main = async () => {
6565
() => swarmManager.getSwarm().connections.size
6666
);
6767

68-
setInterval(() => {
68+
const diagnosticsTimer = setInterval(() => {
6969
broadcastUpdate();
7070
}, DIAGNOSTICS_INTERVAL);
71+
diagnosticsTimer.unref();
7172

7273
const app = createServer(identity, peerManager, swarmManager, sseManager, diagnostics);
73-
startServer(app, identity);
74+
const webServer = startServer(app, identity);
7475

75-
const handleShutdown = () => {
76+
let shuttingDown = false;
77+
const handleShutdown = async () => {
78+
if (shuttingDown) return;
79+
shuttingDown = true;
80+
clearInterval(diagnosticsTimer);
7681
diagnostics.stopLogging();
77-
swarmManager.shutdown();
82+
await swarmManager.shutdown();
83+
webServer.close(() => {
84+
process.exit(0);
85+
});
7886
};
7987

8088
process.on("SIGINT", handleShutdown);

src/p2p/messaging.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const crypto = require("crypto");
77
const {
88
MAX_RELAY_HOPS,
99
ENABLE_CHAT,
10-
CHAT_RATE_LIMIT,
1110
} = require("../config/constants");
1211
const { BloomFilterManager } = require("../state/bloom");
1312
const { generateScreenname } = require("../utils/name-generator");

src/p2p/swarm.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ const Hyperswarm = require("hyperswarm");
22
const { signMessage } = require("../core/security");
33
const {
44
TOPIC,
5-
TOPIC_NAME,
65
HEARTBEAT_INTERVAL,
76
MAX_CONNECTIONS,
87
CONNECTION_ROTATION_INTERVAL,
98
ENABLE_CHAT,
109
} = require("../config/constants");
11-
const { generateScreenname } = require("../utils/name-generator");
1210

1311
class SwarmManager {
1412
constructor(
@@ -122,6 +120,7 @@ class SwarmManager {
122120
this.broadcastFn();
123121
}
124122
}, HEARTBEAT_INTERVAL);
123+
this.heartbeatInterval.unref();
125124
}
126125

127126
startRotation() {
@@ -148,9 +147,10 @@ class SwarmManager {
148147
oldest.destroy();
149148
}
150149
}, CONNECTION_ROTATION_INTERVAL);
150+
this.rotationInterval.unref();
151151
}
152152

153-
shutdown() {
153+
async shutdown() {
154154
const sig = signMessage(
155155
`type:LEAVE:${this.identity.id}`,
156156
this.identity.privateKey
@@ -176,10 +176,8 @@ class SwarmManager {
176176
if (this.rotationInterval) {
177177
clearInterval(this.rotationInterval);
178178
}
179-
180-
setTimeout(() => {
181-
process.exit(0);
182-
}, 500);
179+
this.messageHandler.bloomFilter.stop();
180+
await this.swarm.destroy();
183181
}
184182

185183
getSwarm() {

src/state/peers.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ class PeerManager {
9292
}
9393
return peers;
9494
}
95+
96+
getPeerList() {
97+
const peers = [];
98+
for (const [id, data] of this.seenPeers.entries()) {
99+
peers.push({
100+
id,
101+
seq: data.seq,
102+
lastSeen: data.lastSeen,
103+
ip: data.ip || null,
104+
});
105+
}
106+
return peers;
107+
}
95108
}
96109

97110
module.exports = { PeerManager };

src/web/routes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const setupRoutes = (
4848
peerManager,
4949
swarm,
5050
diagnostics,
51+
sseManager,
5152
};
5253

5354
const chatDeps = {

src/web/routes/stats.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
const { ENABLE_CHAT } = require("../../config/constants");
1+
const { ENABLE_CHAT, ENABLE_MAP } = require("../../config/constants");
2+
const { generateScreenname } = require("../../utils/name-generator");
23

34
const setupStatsRoutes = (router, dependencies) => {
45
const { peerManager, swarm, diagnostics } = dependencies;
@@ -12,9 +13,38 @@ const setupStatsRoutes = (router, dependencies) => {
1213
screenname: dependencies.identity.screenname,
1314
diagnostics: diagnostics.getStats(),
1415
chatEnabled: ENABLE_CHAT,
16+
mapEnabled: ENABLE_MAP,
1517
peers: peerManager.getPeersWithIps(),
1618
});
1719
});
20+
21+
router.get("/api/health", (req, res) => {
22+
res.json({
23+
status: "ok",
24+
uptime: process.uptime(),
25+
peers: peerManager.size,
26+
direct: swarm.getSwarm().connections.size,
27+
sseClients: dependencies.sseManager ? dependencies.sseManager.size : 0,
28+
timestamp: Date.now(),
29+
});
30+
});
31+
32+
router.get("/api/peers", (req, res) => {
33+
const peers = peerManager
34+
.getPeerList()
35+
.sort((a, b) => b.lastSeen - a.lastSeen)
36+
.map((peer) => ({
37+
id: peer.id,
38+
screenname: generateScreenname(peer.id),
39+
lastSeen: peer.lastSeen,
40+
ip: peer.ip,
41+
}));
42+
43+
res.json({
44+
count: peers.length,
45+
peers,
46+
});
47+
});
1848
};
1949

2050
module.exports = { setupStatsRoutes };

src/web/server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ const createServer = (identity, peerManager, swarm, sseManager, diagnostics) =>
1111
}
1212

1313
const startServer = (app, identity) => {
14-
app.listen(PORT, () => {
14+
const server = app.listen(PORT, () => {
1515
console.log(`Hypermind Node running on port ${PORT}`);
1616
console.log(`ID: ${identity.id}`);
1717
});
18+
return server;
1819
}
1920

2021
module.exports = { createServer, startServer };

src/web/sse.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ class SSEManager {
2525
broadcast(data) {
2626
const message = JSON.stringify(data);
2727
for (const client of this.clients) {
28-
client.write(`data: ${message}\n\n`);
28+
if (client.writableEnded || client.destroyed) {
29+
this.clients.delete(client);
30+
continue;
31+
}
32+
try {
33+
client.write(`data: ${message}\n\n`);
34+
} catch (e) {
35+
this.clients.delete(client);
36+
}
2937
}
3038
}
3139

0 commit comments

Comments
 (0)