diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d3dec3c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,19 @@ +# These are supported funding model platforms + +#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +#patreon: # Replace with a single Patreon username +#open_collective: # Replace with a single Open Collective username +#ko_fi: # Replace with a single Ko-fi username +#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +#liberapay: # Replace with a single Liberapay username +#issuehunt: # Replace with a single IssueHunt username +#lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +#polar: # Replace with a single Polar username +#buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +#thanks_dev: # Replace with a single thanks.dev username +#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] + +github: lklynet +buy_me_a_coffee: lkly +thanks_dev: lklynet diff --git a/README.md b/README.md index fe6d741..dbf4ec9 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,33 @@ kubectl expose deployment hypermind --type=LoadBalancer --port=3000 --target-por ``` +## » Configuration + +You can customize Hypermind's behavior using environment variables. + +### Extras +These features are disabled by default. Set them to `true` to enable. + +| Variable | Default | Description | +|----------|---------|-------------| +| `ENABLE_CHAT` | `false` | Enables the decentralized chat system. | +| `ENABLE_MAP` | `false` | Enables map visualization features. | + +### Refinement +Tune the network parameters to fit your system resources. The defaults are safe for most users. Don't change unless you know what you're doing. + +| Variable | Default | Description | +|----------|---------|-------------| +| `MAX_PEERS` | `50000` | Maximum number of peers to track in memory. | +| `MAX_MESSAGE_SIZE` | `2048` | Maximum size of a single message in bytes. | +| `MAX_RELAY_HOPS` | `2` | Maximum number of times a message is relayed. | +| `MAX_CONNECTIONS` | `15` | Maximum number of active P2P connections. | +| `HEARTBEAT_INTERVAL` | `30000` | How often (ms) to send heartbeat messages. | +| `CONNECTION_ROTATION_INTERVAL` | `300000` | How often (ms) to rotate connections. | +| `PEER_TIMEOUT` | `45000` | Time (ms) before a silent peer is considered offline. | +| `CHAT_RATE_LIMIT` | `5000` | Time window (ms) for chat rate limiting. | +| `VISUAL_LIMIT` | `500` | Max number of particles to render on the dashboard. | + ## » Ecosystem & Integrations The community has bravely stepped up to integrate Hypermind into critical monitoring infrastructure. @@ -125,7 +152,29 @@ See detailed [instructions](https://gethomepage.dev/configs/services/#icons). | Variable | Default | Description | | --- | --- | --- | | `PORT` | `3000` | The port the web dashboard listens on. Since `--network host` is used, this port opens directly on the host. | -| `MAX_PEERS` | `1000000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. | +| `MAX_PEERS` | `50000` | 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 (Default):** You can only talk to your ~32 direct connections. +* **Global Mode:** Use `/global your message` to broadcast messages to the entire swarm (relayed via gossip). +* **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`. + +**Commands:** +* `/global on` - Enable global chat mode. +* `/global off` - Disable global chat mode (local only). +* `/global ` - Send a single message to the global swarm without switching modes. ## » Usage diff --git a/docker-compose.yml b/docker-compose.yml index fbf9053..4037c84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,18 @@ services: restart: unless-stopped environment: - PORT=3000 - - MAX_PEERS=10000 + + # --- Add-ons --- + # - ENABLE_CHAT=false # Enable decentralized chat + # - ENABLE_MAP=false # Enable peer map visualization + + # --- Refinements --- + # - MAX_PEERS=50000 # Max peers to track in memory + # - MAX_CONNECTIONS=15 # Max active P2P connections + # - HEARTBEAT_INTERVAL=30000 # Heartbeat frequency (ms) + # - CONNECTION_ROTATION_INTERVAL=300000 # Connection rotation frequency (ms) + # - PEER_TIMEOUT=45000 # Time before peer is considered offline (ms) + # - MAX_MESSAGE_SIZE=2048 # Max message size (bytes) + # - MAX_RELAY_HOPS=2 # Max message relay hops + # - CHAT_RATE_LIMIT=5000 # Chat rate limit window (ms) + # - VISUAL_LIMIT=500 # Max particles on dashboard diff --git a/package.json b/package.json index 8d6ea5e..ed78a3d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "main": "server.js", "scripts": { "start": "node server.js", + "start:dev1": "PORT=3000 ENABLE_CHAT=true TOPIC_NAME=hypermind-dev node server.js", + "start:dev2": "PORT=3001 ENABLE_CHAT=true TOPIC_NAME=hypermind-dev node server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { diff --git a/public/app.js b/public/app.js index b65e53b..039eeab 100644 --- a/public/app.js +++ b/public/app.js @@ -42,7 +42,8 @@ class Particle { } const updateParticles = (count) => { - const VISUAL_LIMIT = 500; + const limitAttr = canvas.getAttribute('data-visual-limit'); + const VISUAL_LIMIT = limitAttr ? parseInt(limitAttr) : 500; const visualCount = Math.min(count, VISUAL_LIMIT); const currentCount = particles.length; @@ -100,6 +101,295 @@ document.getElementById('diagnosticsModal').addEventListener('click', (e) => { document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeDiagnostics(); + closeMap(); + } +}); + +// Map Logic +let map = null; +let mapInitialized = false; +let peerMarkers = {}; // id -> marker +let ipCache = {}; // ip -> { lat, lon } +let lastPeerData = []; +let myLocation = null; + +const fetchMyLocation = async () => { + if (myLocation) return; + try { + const res = await fetch('https://ipwho.is/'); + const data = await res.json(); + if (data.success) { + myLocation = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country }; + updateMap(lastPeerData); + } + } catch (e) { + console.error('My location fetch failed', e); + } +} + +const openMap = () => { + document.getElementById('mapModal').classList.add('active'); + if (!mapInitialized) { + initMap(); + } else { + setTimeout(() => { + map.invalidateSize(); + }, 100); + } + + fetchMyLocation(); + + if (lastPeerData.length > 0) { + updateMap(lastPeerData); + } +} + +const closeMap = () => { + document.getElementById('mapModal').classList.remove('active'); +} + +document.getElementById('mapModal').addEventListener('click', (e) => { + if (e.target.id === 'mapModal') { + closeMap(); + } +}); + +const initMap = () => { + if (mapInitialized) return; + + map = L.map('map').setView([20, 0], 2); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19 + }).addTo(map); + + mapInitialized = true; + + setTimeout(() => { + map.invalidateSize(); + }, 100); +} + +const fetchLocation = async (ip) => { + if (ipCache[ip]) return ipCache[ip]; + + // Skip local IPs + if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.16.')) { + return null; + } + + try { + const res = await fetch(`https://ipwho.is/${ip}`); + const data = await res.json(); + if (data.success) { + const loc = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country }; + ipCache[ip] = loc; + return loc; + } + } catch (e) { + console.error('Geo fetch failed', e); + } + return null; +} + +const updateMap = async (peers) => { + if (!mapInitialized) return; + if (!peers) peers = []; + + const currentIds = new Set(peers.map(p => p.id)); + + // Remove old markers + for (const id in peerMarkers) { + if (id !== 'me' && !currentIds.has(id)) { + map.removeLayer(peerMarkers[id]); + delete peerMarkers[id]; + } + } + + // Add/Update markers + for (const peer of peers) { + if (!peer.ip) continue; + + if (!peerMarkers[peer.id]) { + const loc = await fetchLocation(peer.ip); + if (loc) { + const marker = L.circleMarker([loc.lat, loc.lon], { + radius: 10, + fillColor: "#4ade80", + color: "transparent", + weight: 0, + opacity: 0, + fillOpacity: 0.15 + }).addTo(map); + + marker.bindPopup(`Node ${peer.id.slice(-8)}
${loc.city}, ${loc.country}`); + peerMarkers[peer.id] = marker; + } + } + } + + // Add My Location + if (myLocation && !peerMarkers['me']) { + const marker = L.circleMarker([myLocation.lat, myLocation.lon], { + radius: 6, + fillColor: "#ffffff", + color: "#4ade80", + weight: 2, + opacity: 1, + fillOpacity: 1 + }).addTo(map); + + marker.bindPopup(`This Node
${myLocation.city}, ${myLocation.country}`); + peerMarkers['me'] = marker; + } +} + +const terminal = document.getElementById('terminal'); +const terminalOutput = document.getElementById('terminal-output'); +const systemStatusBar = document.getElementById('system-status-bar'); +const terminalInput = document.getElementById('terminal-input'); +const terminalToggle = document.getElementById('terminal-toggle'); +const mapContainer = document.getElementById('map-container'); +const promptEl = document.querySelector('.prompt'); +let myId = null; +let myChatHistory = []; +let globalChatEnabled = true; + +terminalToggle.addEventListener('click', (e) => { + e.stopPropagation(); + toggleChat(); +}); + +// Initialize chat state from localStorage +const initChatState = () => { + const isCollapsed = localStorage.getItem('chatCollapsed') === 'true'; + if (isCollapsed) { + terminal.classList.add('collapsed'); + terminalToggle.innerText = '▲'; + document.body.classList.remove('chat-active'); + document.body.classList.add('chat-collapsed'); + } else { + terminal.classList.remove('collapsed'); + terminalToggle.innerText = '▼'; + document.body.classList.add('chat-active'); + document.body.classList.remove('chat-collapsed'); + } +}; + +const toggleChat = () => { + terminal.classList.toggle('collapsed'); + const isCollapsed = terminal.classList.contains('collapsed'); + terminalToggle.innerText = isCollapsed ? '▲' : '▼'; + + localStorage.setItem('chatCollapsed', isCollapsed); + + if (isCollapsed) { + document.body.classList.remove('chat-active'); + document.body.classList.add('chat-collapsed'); + } else { + document.body.classList.add('chat-active'); + document.body.classList.remove('chat-collapsed'); + terminalOutput.scrollTop = terminalOutput.scrollHeight; + } +} + +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 === 'CHAT') { + const senderColor = getColorFromId(msg.sender); + const senderName = msg.sender === myId ? 'You' : msg.sender.slice(-4); + const scopeLabel = msg.scope === 'GLOBAL' ? '[GLOBAL] ' : ''; + + const senderSpan = document.createElement('span'); + senderSpan.className = 'msg-sender'; + senderSpan.style.color = senderColor; + senderSpan.innerText = `${scopeLabel}[${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') { + let content = terminalInput.value.trim(); + if (!content) return; + + terminalInput.value = ''; + + if (content === '/global on') { + globalChatEnabled = true; + systemStatusBar.innerText = `[SYSTEM] Global chat messages enabled.`; + return; + } + + if (content === '/global off') { + globalChatEnabled = false; + systemStatusBar.innerText = `[SYSTEM] Global chat messages disabled.`; + return; + } + + let scope = 'LOCAL'; + if (content.startsWith('/global ')) { + scope = 'GLOBAL'; + content = content.replace(/^\/global\s+/, '').trim(); + if (!content) return; + } + + try { + const res = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, scope }) + }); + + 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); + } } }); @@ -108,6 +398,51 @@ const evtSource = new EventSource("/events"); evtSource.onmessage = (event) => { const data = JSON.parse(event.data); + if (data.type === 'SYSTEM') { + systemStatusBar.innerText = `[SYSTEM] ${data.content}`; + return; + } + + if (data.type === 'CHAT') { + if (data.scope === 'GLOBAL' && !globalChatEnabled) { + return; + } + appendMessage(data); + return; + } + + if (data.chatEnabled) { + terminal.classList.remove('hidden'); + + // Only initialize state once when chat becomes enabled + if (!terminal.dataset.initialized) { + initChatState(); + terminal.dataset.initialized = 'true'; + } + } else { + terminal.classList.add('hidden'); + document.body.classList.remove('chat-active'); + document.body.classList.remove('chat-collapsed'); + } + + if (data.mapEnabled) { + if (mapContainer) mapContainer.style.display = 'inline'; + } else { + if (mapContainer) mapContainer.style.display = 'none'; + if (document.getElementById('mapModal').classList.contains('active')) { + closeMap(); + } + } + + if (data.id) myId = data.id; + + if (data.peers) { + lastPeerData = data.peers; + if (mapInitialized && document.getElementById('mapModal').classList.contains('active')) { + updateMap(data.peers); + } + } + updateParticles(data.count); if (countEl.innerText != data.count) { diff --git a/public/index.html b/public/index.html index 402d51c..0df7511 100644 --- a/public/index.html +++ b/public/index.html @@ -6,9 +6,11 @@ + + - +
{{COUNT}}
Active Nodes
@@ -19,6 +21,14 @@ ID: {{ID}}
Direct Connections: {{DIRECT}}
diagnostics + | map +
+ + + @@ -69,6 +79,15 @@ + diff --git a/public/style.css b/public/style.css index 98e5dbb..b78a11d 100644 --- a/public/style.css +++ b/public/style.css @@ -9,18 +9,36 @@ body { background: var(--color-bg-main); color: var(--color-text-default); margin: 0; + transition: padding-bottom 0.3s ease; +} + +body.chat-active { + padding-bottom: 250px; +} + +body.chat-collapsed { + padding-bottom: 40px; } .container { text-align: center; position: relative; z-index: 10; } #network { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; } .count { font-size: 8rem; font-weight: bold; color: var(--color-count); transition: color 0.2s; visibility: hidden; } .count.loaded { visibility: visible; } -.label { font-size: 1.5rem; color: var(--color-text-main-label); margin-top: 1rem; } -.footer { margin-top: 2rem; font-size: 0.9rem; color: var(--color-text-footer); } -.debug { font-size: 0.8rem; color: var(--color-text-debug); margin-top: 1rem; } -.debug-link { color: var(--color-text-debug-link); border-bottom: 1px dotted; cursor: pointer; transition: color 0.2s; } -.debug-link:hover { color: var(--color-text-debug-link-hover); } -a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1px dotted var(--color-text-secondary); } +.label { font-size: 1.5rem; color: #9ca3af; margin-top: 1rem; } +.footer { + margin: 2rem auto 0; + font-size: 0.9rem; + color: #9ca3af; +} +.debug { + font-size: 0.8rem; + color: #9ca3af; + margin: 1rem auto 0; +} +.debug-link { color: #9ca3af; border-bottom: 1px dotted #9ca3af; cursor: pointer; } +.debug-link:hover { color: #e5e7eb; } +a { color: #9ca3af; text-decoration: none; border-bottom: 1px dotted #9ca3af; } + .pulse { animation: pulse 0.5s ease-in-out; } @keyframes pulse { 0% { transform: scale(1); } @@ -56,6 +74,23 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p width: 90%; position: relative; } +.modal-content.map-content { + max-width: 1000px; + width: 100%; + height: 500px; + max-height: 80vh; + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +#map { + width: 100%; + height: 100%; + background: #222; +} + .modal-title { font-size: 0.9rem; color: var(--color-modal-title); @@ -63,6 +98,7 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p text-transform: uppercase; letter-spacing: 1px; } + .close-btn { position: absolute; top: 1.5rem; @@ -75,6 +111,12 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p transition: color 0.2s; } .close-btn:hover { color: var(--color-modal-close-btn-hover); } + color: #9ca3af; + font-size: 1.2rem; + cursor: pointer; + z-index: 1001; +} +.close-btn:hover { color: #fff; } .stat-row { display: flex; justify-content: space-between; @@ -118,4 +160,140 @@ a { color: var(--color-text-secondary); text-decoration: none; border-bottom: 1p .theme-btn:hover .material-symbols-outlined { color: var(--color-theme-toggle-hover); + +.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); + transition: transform 0.3s ease; +} + +.hidden { + display: none !important; +} + +.terminal.hidden { + display: none; +} + +.terminal.collapsed { + transform: translateX(-50%) translateY(100%); +} + +.terminal-toggle { + position: absolute; + top: -24px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.9); + border: 1px solid #333; + border-bottom: none; + border-radius: 8px 8px 0 0; + color: #4ade80; + cursor: pointer; + font-family: monospace; + font-weight: bold; + font-size: 14px; + z-index: 101; + padding: 2px 15px; + height: 24px; + line-height: 20px; + display: flex; + align-items: center; + justify-content: center; +} + +.terminal-toggle:hover { + color: #fff; + background: #222; +} + +.system-status-bar { + height: 20px; + margin-bottom: 5px; + color: #666; + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; +} + +.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; } diff --git a/server.js b/server.js index 3a690a0..40c9757 100644 --- a/server.js +++ b/server.js @@ -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, ENABLE_MAP } = require("./src/config/constants"); const main = async () => { const identity = generateIdentity(); @@ -25,14 +25,27 @@ const main = async () => { direct: swarmManager.getSwarm().connections.size, id: identity.id, diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + mapEnabled: ENABLE_MAP, + peers: peerManager.getPeersWithIps() }); }; + 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( @@ -41,7 +54,8 @@ const main = async () => { diagnostics, messageHandler, (msg, sourceSocket) => relayMessage(msg, sourceSocket, swarmManager.getSwarm(), diagnostics), - broadcastUpdate + broadcastUpdate, + chatSystemFn ); await swarmManager.start(); diff --git a/src/config/constants.js b/src/config/constants.js index aba2c63..2f1663b 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -1,42 +1,54 @@ const crypto = require("crypto"); -const TOPIC_NAME = "hypermind-lklynet-v1"; +const TOPIC_NAME = process.env.TOPIC_NAME || "hypermind-lklynet-v1"; const TOPIC = crypto.createHash("sha256").update(TOPIC_NAME).digest(); /** * fccview here, frankly I don't think I can make this more secure, we can change it to `00000` but * that means until everyone upgrade there'll be a divide between nodes. - * + * * I ran it that way and I was fairly isolated, with hundreds of failed POW, shame. * adding an extra 0 makes it very expensive on attacker to make it worth the fun for them, so maybe consider it. - * + * ---- + * ricardoboss: added a way to get the best of both worlds: newer nodes will use a harder POW, making them compatible + * with others who use a harder POW while still being able to accept "old" POWs from clients */ -const POW_PREFIX = "0000"; +const MY_POW_PREFIX = "00000"; +const VERIFICATION_POW_PREFIX = "0000"; -const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 1000000; -const MAX_MESSAGE_SIZE = 2048; -const MAX_RELAY_HOPS = 2; -const MAX_CONNECTIONS = 32; +const MAX_PEERS = parseInt(process.env.MAX_PEERS) || 50000; +const MAX_MESSAGE_SIZE = parseInt(process.env.MAX_MESSAGE_SIZE) || 2048; +const MAX_RELAY_HOPS = parseInt(process.env.MAX_RELAY_HOPS) || 2; +const MAX_CONNECTIONS = parseInt(process.env.MAX_CONNECTIONS) || 15; -const HEARTBEAT_INTERVAL = 5000; -const CONNECTION_ROTATION_INTERVAL = 30000; -const PEER_TIMEOUT = 15000; +const HEARTBEAT_INTERVAL = parseInt(process.env.HEARTBEAT_INTERVAL) || 30000; +const CONNECTION_ROTATION_INTERVAL = parseInt(process.env.CONNECTION_ROTATION_INTERVAL) || 300000; +const PEER_TIMEOUT = parseInt(process.env.PEER_TIMEOUT) || 45000; const BROADCAST_THROTTLE = 1000; const DIAGNOSTICS_INTERVAL = 10000; const PORT = process.env.PORT || 3000; +const ENABLE_CHAT = process.env.ENABLE_CHAT === "true"; +const ENABLE_MAP = process.env.ENABLE_MAP === 'true'; +const CHAT_RATE_LIMIT = parseInt(process.env.CHAT_RATE_LIMIT) || 5000; +const VISUAL_LIMIT = parseInt(process.env.VISUAL_LIMIT) || 500; module.exports = { - TOPIC_NAME, - TOPIC, - POW_PREFIX, - MAX_PEERS, - MAX_MESSAGE_SIZE, - MAX_RELAY_HOPS, - MAX_CONNECTIONS, - HEARTBEAT_INTERVAL, - CONNECTION_ROTATION_INTERVAL, - PEER_TIMEOUT, - BROADCAST_THROTTLE, - DIAGNOSTICS_INTERVAL, - PORT, + TOPIC_NAME, + TOPIC, + MY_POW_PREFIX, + VERIFICATION_POW_PREFIX, + MAX_PEERS, + MAX_MESSAGE_SIZE, + MAX_RELAY_HOPS, + MAX_CONNECTIONS, + HEARTBEAT_INTERVAL, + CONNECTION_ROTATION_INTERVAL, + PEER_TIMEOUT, + BROADCAST_THROTTLE, + DIAGNOSTICS_INTERVAL, + PORT, + ENABLE_CHAT, + ENABLE_MAP, + CHAT_RATE_LIMIT, + VISUAL_LIMIT, }; diff --git a/src/core/identity.js b/src/core/identity.js index 0c64857..75594d6 100644 --- a/src/core/identity.js +++ b/src/core/identity.js @@ -1,5 +1,5 @@ const crypto = require("crypto"); -const { POW_PREFIX } = require("../config/constants"); +const { MY_POW_PREFIX } = require("../config/constants"); const generateIdentity = () => { const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); @@ -11,7 +11,7 @@ const generateIdentity = () => { .createHash("sha256") .update(id + nonce) .digest("hex"); - if (hash.startsWith(POW_PREFIX)) break; + if (hash.startsWith(MY_POW_PREFIX)) break; nonce++; } diff --git a/src/core/security.js b/src/core/security.js index 9544583..62eed00 100644 --- a/src/core/security.js +++ b/src/core/security.js @@ -1,5 +1,5 @@ const crypto = require("crypto"); -const { POW_PREFIX } = require("../config/constants"); +const { VERIFICATION_POW_PREFIX } = require("../config/constants"); const verifyPoW = (id, nonce) => { if (!nonce) return false; @@ -7,7 +7,7 @@ const verifyPoW = (id, nonce) => { .createHash("sha256") .update(id + nonce) .digest("hex"); - return powHash.startsWith(POW_PREFIX); + return powHash.startsWith(VERIFICATION_POW_PREFIX); } const signMessage = (message, privateKey) => { diff --git a/src/p2p/messaging.js b/src/p2p/messaging.js index 67967c6..5597b71 100644 --- a/src/p2p/messaging.js +++ b/src/p2p/messaging.js @@ -1,133 +1,251 @@ -const { verifyPoW, verifySignature, createPublicKey } = require("../core/security"); -const { MAX_RELAY_HOPS } = require("../config/constants"); +const { + verifyPoW, + verifySignature, + createPublicKey, +} = require("../core/security"); +const { MAX_RELAY_HOPS, ENABLE_CHAT, CHAT_RATE_LIMIT } = require("../config/constants"); const { BloomFilterManager } = require("../state/bloom"); class MessageHandler { - constructor(peerManager, diagnostics, relayCallback, broadcastCallback) { - this.peerManager = peerManager; - this.diagnostics = diagnostics; - this.relayCallback = relayCallback; - this.broadcastCallback = broadcastCallback; - this.bloomFilter = new BloomFilterManager(); - this.bloomFilter.start(); + 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) { + if (!validateMessage(msg)) { + return; } - handleMessage(msg, sourceSocket) { - if (!validateMessage(msg)) { - return; - } + if (msg.type === "HEARTBEAT") { + this.handleHeartbeat(msg, sourceSocket); + } else if (msg.type === "LEAVE") { + this.handleLeave(msg, sourceSocket); + } else if (msg.type === "CHAT") { + this.handleChat(msg, sourceSocket); + } + } - if (msg.type === "HEARTBEAT") { - this.handleHeartbeat(msg, sourceSocket); - } else if (msg.type === "LEAVE") { - this.handleLeave(msg, sourceSocket); - } + handleHeartbeat(msg, sourceSocket) { + this.diagnostics.increment("heartbeatsReceived"); + const { id, seq, hops, nonce, sig } = msg; + + // Optimization: Check for duplicates BEFORE verifyPoW (CPU intensive) + const stored = this.peerManager.getPeer(id); + if (stored && seq <= stored.seq) { + this.diagnostics.increment("duplicateSeq"); + return; } - handleHeartbeat(msg, sourceSocket) { - this.diagnostics.increment("heartbeatsReceived"); - const { id, seq, hops, nonce, sig } = msg; - - if (!verifyPoW(id, nonce)) { - this.diagnostics.increment("invalidPoW"); - return; - } - - const stored = this.peerManager.getPeer(id); - if (stored && seq <= stored.seq) { - this.diagnostics.increment("duplicateSeq"); - return; - } - - if (!sig) return; - - try { - // Check if we can accept new peers (only matters for new peers) - if (!stored && !this.peerManager.canAcceptPeer(id)) return; - - // Derive public key on-demand from peer ID - const key = createPublicKey(id); - - if (!verifySignature(`seq:${seq}`, sig, key)) { - this.diagnostics.increment("invalidSig"); - return; - } - - if (hops === 0) { - sourceSocket.peerId = id; - } - - const wasNew = this.peerManager.addOrUpdatePeer(id, seq); - - if (wasNew) { - this.diagnostics.increment("newPeersAdded"); - this.broadcastCallback(); - } - - // Only relay if we haven't already relayed this message (bloom filter check) - if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, seq)) { - this.bloomFilter.markRelayed(id, seq); - this.diagnostics.increment("heartbeatsRelayed"); - this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket); - } - } catch (e) { - return; - } + if (!verifyPoW(id, nonce)) { + this.diagnostics.increment("invalidPoW"); + return; } - handleLeave(msg, sourceSocket) { - this.diagnostics.increment("leaveMessages"); - const { id, hops, sig } = msg; + if (!sig) return; - if (!sig) return; + try { + // Check if we can accept new peers (only matters for new peers) + if (!stored && !this.peerManager.canAcceptPeer(id)) return; - // Only process leave messages for peers we know about - if (!this.peerManager.hasPeer(id)) return; + // Derive public key on-demand from peer ID + const key = createPublicKey(id); - // Derive public key on-demand from peer ID - const key = createPublicKey(id); + if (!verifySignature(`seq:${seq}`, sig, key)) { + this.diagnostics.increment("invalidSig"); + return; + } - if (!verifySignature(`type:LEAVE:${id}`, sig, key)) { + if (hops === 0) { + sourceSocket.peerId = id; + } + + const getIp = (sock) => { + if (sock.remoteAddress) return sock.remoteAddress; + if (sock.rawStream && sock.rawStream.remoteHost) + return sock.rawStream.remoteHost; + if (sock.rawStream && sock.rawStream.remoteAddress) + return sock.rawStream.remoteAddress; + return null; + }; + + const ip = hops === 0 ? getIp(sourceSocket) : null; + const wasNew = this.peerManager.addOrUpdatePeer(id, seq, ip); + + 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) + if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, seq)) { + this.bloomFilter.markRelayed(id, seq); + this.diagnostics.increment("heartbeatsRelayed"); + this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket); + } + } catch (e) { + return; + } + } + + handleLeave(msg, sourceSocket) { + this.diagnostics.increment("leaveMessages"); + const { id, hops, sig } = msg; + + if (!sig) return; + + // Only process leave messages for peers we know about + if (!this.peerManager.hasPeer(id)) return; + + // Derive public key on-demand from peer ID + const key = createPublicKey(id); + + if (!verifySignature(`type:LEAVE:${id}`, sig, key)) { + this.diagnostics.increment("invalidSig"); + return; + } + + if (this.peerManager.hasPeer(id)) { + 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"); + this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket); + } + } + } + + handleChat(msg, sourceSocket) { + const { scope, sender, id, sig, hops } = msg; + + // Rate Limiting (apply to all chat messages) + const now = Date.now(); + let rateData = this.chatRateLimits.get(sender); + + if (!rateData || now - rateData.windowStart > 10000) { + // Reset window + rateData = { count: 0, windowStart: now }; + } + + if (rateData.count >= 5) { + return; // Drop message + } + + if (!scope || scope === 'LOCAL') { + // Identity Verification: Ensure the sender matches the authenticated socket + if (!sourceSocket.peerId || sourceSocket.peerId !== sender) { + return; + } + + rateData.count++; + this.chatRateLimits.set(sender, rateData); + + if (this.chatCallback) { + this.chatCallback(msg); + } + } else if (scope === 'GLOBAL') { + if (!sig || !id) return; + + // Check signature + const key = createPublicKey(sender); + if (!verifySignature(`chat:${id}`, sig, key)) { this.diagnostics.increment("invalidSig"); return; } - if (this.peerManager.hasPeer(id)) { - this.peerManager.removePeer(id); - this.broadcastCallback(); + // Deduplication + if (this.bloomFilter.hasRelayed(id, "chat")) { + return; + } + this.bloomFilter.markRelayed(id, "chat"); - // Use id:leave as key for LEAVE messages - if (hops < MAX_RELAY_HOPS && !this.bloomFilter.hasRelayed(id, "leave")) { - this.bloomFilter.markRelayed(id, "leave"); - this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket); - } + rateData.count++; + this.chatRateLimits.set(sender, rateData); + + if (this.chatCallback) { + this.chatCallback(msg); + } + + // Relay + if (hops < MAX_RELAY_HOPS) { + this.relayCallback({ ...msg, hops: hops + 1 }, sourceSocket); } } + } } const validateMessage = (msg) => { - if (!msg || typeof msg !== 'object') return false; - if (!msg.type) return false; + if (!msg || typeof msg !== "object") return false; + if (!msg.type) return false; - const msgSize = JSON.stringify(msg).length; - if (msgSize > require("../config/constants").MAX_MESSAGE_SIZE) return false; + const msgSize = JSON.stringify(msg).length; + if (msgSize > require("../config/constants").MAX_MESSAGE_SIZE) return false; - if (msg.type === "HEARTBEAT") { - const allowedFields = ['type', 'id', 'seq', 'hops', 'nonce', 'sig']; - const fields = Object.keys(msg); - return fields.every(f => allowedFields.includes(f)) && - msg.id && typeof msg.seq === 'number' && - typeof msg.hops === 'number' && msg.nonce && msg.sig; - } + if (msg.type === "HEARTBEAT") { + const allowedFields = ["type", "id", "seq", "hops", "nonce", "sig"]; + const fields = Object.keys(msg); + return ( + fields.every((f) => allowedFields.includes(f)) && + msg.id && + typeof msg.seq === "number" && + typeof msg.hops === "number" && + msg.nonce && + msg.sig + ); + } - if (msg.type === "LEAVE") { - const allowedFields = ['type', 'id', 'hops', 'sig']; - const fields = Object.keys(msg); - return fields.every(f => allowedFields.includes(f)) && - msg.id && typeof msg.hops === 'number' && msg.sig; - } + if (msg.type === "LEAVE") { + const allowedFields = ["type", "id", "hops", "sig"]; + const fields = Object.keys(msg); + return ( + fields.every((f) => allowedFields.includes(f)) && + msg.id && + typeof msg.hops === "number" && + msg.sig + ); + } - return false; -} + if (msg.type === "CHAT") { + const allowedFields = ['type', 'sender', 'content', 'timestamp', 'scope', 'id', 'sig', 'hops']; + 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; +}; module.exports = { MessageHandler, validateMessage }; diff --git a/src/p2p/relay.js b/src/p2p/relay.js index 1bc6745..70f708f 100644 --- a/src/p2p/relay.js +++ b/src/p2p/relay.js @@ -1,16 +1,33 @@ const relayMessage = (msg, sourceSocket, swarm, diagnostics) => { - const data = JSON.stringify(msg) + "\n"; - const relayCount = swarm.connections.size - 1; + const data = JSON.stringify(msg) + "\n"; - if (diagnostics) { - diagnostics.increment("bytesRelayed", data.length * relayCount); - } + // Gossip Subsampling: + // Instead of flooding everyone (which causes massive bandwidth usage with 50 connections), + // we relay to a random subset of peers (e.g., 6). + // This maintains "Epidemic" reach (O(log N)) while capping bandwidth. - for (const socket of swarm.connections) { - if (socket !== sourceSocket) { - socket.write(data); - } + const TARGET_GOSSIP_COUNT = 6; + const allSockets = Array.from(swarm.connections); + const eligible = allSockets.filter((s) => s !== sourceSocket); + + let targets = eligible; + + if (eligible.length > TARGET_GOSSIP_COUNT) { + // Fisher-Yates shuffle (partial) to pick random peers + for (let i = eligible.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [eligible[i], eligible[j]] = [eligible[j], eligible[i]]; } -} + targets = eligible.slice(0, TARGET_GOSSIP_COUNT); + } + + if (diagnostics) { + diagnostics.increment("bytesRelayed", data.length * targets.length); + } + + for (const socket of targets) { + socket.write(data); + } +}; module.exports = { relayMessage }; diff --git a/src/p2p/swarm.js b/src/p2p/swarm.js index a9b3fc9..2757eff 100644 --- a/src/p2p/swarm.js +++ b/src/p2p/swarm.js @@ -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 }; diff --git a/src/state/bloom.js b/src/state/bloom.js index f0a6c04..378a16e 100644 --- a/src/state/bloom.js +++ b/src/state/bloom.js @@ -3,7 +3,7 @@ * Prevents re-relaying messages we've already seen */ class BloomFilter { - constructor(size = 10000, hashCount = 3) { + constructor(size = 200000, hashCount = 3) { this.size = size; this.hashCount = hashCount; this.bits = new Uint8Array(Math.ceil(size / 8)); diff --git a/src/state/peers.js b/src/state/peers.js index 829128e..7b7882d 100644 --- a/src/state/peers.js +++ b/src/state/peers.js @@ -9,7 +9,7 @@ class PeerManager { this.mySeq = 0; } - addOrUpdatePeer(id, seq) { + addOrUpdatePeer(id, seq, ip = null) { const stored = this.seenPeers.get(id); const wasNew = !stored; @@ -19,6 +19,7 @@ class PeerManager { this.seenPeers.set(id, { seq, lastSeen: Date.now(), + ip: ip || (stored ? stored.ip : null), }); return wasNew; @@ -49,6 +50,11 @@ class PeerManager { if (now - data.lastSeen > PEER_TIMEOUT) { this.seenPeers.delete(id); removed++; + } else { + // Optimization: Since LRUCache maintains insertion order (updated on access), + // the Map is sorted by lastSeen (ascending). + // If we find a non-stale peer, all subsequent peers are also non-stale. + break; } } @@ -70,6 +76,16 @@ class PeerManager { getSeq() { return this.mySeq; } + + getPeersWithIps() { + const peers = []; + for (const [id, data] of this.seenPeers.entries()) { + if (data.ip) { + peers.push({ id, ip: data.ip }); + } + } + return peers; + } } module.exports = { PeerManager }; diff --git a/src/web/routes.js b/src/web/routes.js index 8a915d9..7258c2b 100644 --- a/src/web/routes.js +++ b/src/web/routes.js @@ -1,6 +1,9 @@ const express = require("express"); const fs = require("fs"); const path = require("path"); +const crypto = require("crypto"); +const { signMessage } = require("../core/security"); +const { ENABLE_CHAT, ENABLE_MAP, CHAT_RATE_LIMIT, VISUAL_LIMIT } = require("../config/constants"); const HTML_TEMPLATE = fs.readFileSync( path.join(__dirname, "../../public/index.html"), @@ -8,14 +11,18 @@ 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(/\{\{DIRECT\}\}/g, directPeers); + .replace(/\{\{ID\}\}/g, "..." + identity.id.slice(-8)) + .replace(/\{\{DIRECT\}\}/g, directPeers) + .replace(/\{\{MAP_CLASS\}\}/g, ENABLE_MAP ? '' : 'hidden') + .replace(/\{\{VISUAL_LIMIT\}\}/g, VISUAL_LIMIT); res.send(html); }); @@ -34,6 +41,8 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) direct: swarm.getSwarm().connections.size, id: identity.id, diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + peers: peerManager.getPeersWithIps() }); res.write(`data: ${data}\n\n`); @@ -49,9 +58,62 @@ const setupRoutes = (app, identity, peerManager, swarm, sseManager, diagnostics) direct: swarm.getSwarm().connections.size, id: identity.id, diagnostics: diagnostics.getStats(), + chatEnabled: ENABLE_CHAT, + peers: peerManager.getPeersWithIps() }); }); + 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 CHAT_RATE_LIMIT) + chatHistory = chatHistory.filter(time => now - time < CHAT_RATE_LIMIT); + + if (chatHistory.length >= 5) { + return res.status(429).json({ error: `Rate limit exceeded: Max 5 messages per ${CHAT_RATE_LIMIT / 1000} seconds` }); + } + + chatHistory.push(now); + + const { content, scope = 'LOCAL' } = req.body; + if (!content || typeof content !== 'string' || content.length > 140) { + return res.status(400).json({ error: "Invalid content" }); + } + + if (scope !== 'LOCAL' && scope !== 'GLOBAL') { + return res.status(400).json({ error: "Invalid scope" }); + } + + const timestamp = Date.now(); + // Create a unique ID that depends on content to prevent replay/duplicates + const idBase = identity.id + content + timestamp; + const msgId = crypto.createHash('sha256').update(idBase).digest('hex'); + + const msg = { + type: "CHAT", + id: msgId, + sender: identity.id, + content: content, + timestamp: timestamp, + scope: scope, + hops: 0 + }; + + if (scope === 'GLOBAL') { + msg.sig = signMessage(`chat:${msgId}`, identity.privateKey); + } + + swarm.broadcastChat(msg); + sseManager.broadcast(msg); + + res.json({ success: true }); + }); + app.use(express.static(path.join(__dirname, "../../public"))); } diff --git a/src/web/sse.js b/src/web/sse.js index c80f3cc..27e990e 100644 --- a/src/web/sse.js +++ b/src/web/sse.js @@ -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`);