Skip to content

Commit a5bb762

Browse files
authored
Merge pull request #203 from Mozez155/feat/websocket-robust
feat: robust real-time WebSocket communication system
2 parents 9f6cc47 + 9753e2f commit a5bb762

3 files changed

Lines changed: 203 additions & 23 deletions

File tree

backend/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ FEE_BUMP_THRESHOLD_XLM=2
3131
# Security
3232
JWT_SECRET=change-me
3333

34+
# WebSocket
35+
# HMAC secret for signing outbound WebSocket message envelopes
36+
# WS_MSG_SECRET=your-strong-random-secret
37+
3438
# Optional: decrypt ENC(...) values
3539
# CONFIG_ENCRYPTION_KEY=your-strong-key
3640

backend/src/routes/metrics.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from 'express';
22
import { getSnapshot, resetMetrics } from '../monitoring/metrics.js';
3+
import { getWsStats } from '../services/websocket.js';
34
import { getFeeBumpStats } from '../services/stellar.js';
45
import { getCdnStats } from '../cdn/index.js';
56
import { checkShardHealth, getShardStats } from '../db/sharding.js';
@@ -17,6 +18,9 @@ router.delete('/', (_req, res) => {
1718
res.json({ message: 'Metrics reset' });
1819
});
1920

21+
// GET /api/metrics/websocket — live WebSocket analytics
22+
router.get('/websocket', (_req, res) => {
23+
res.json(getWsStats());
2024
// GET /api/metrics/fee-bump — fee bump usage stats for cost tracking
2125
router.get('/fee-bump', (_req, res) => {
2226
res.json(getFeeBumpStats());

backend/src/services/websocket.js

Lines changed: 195 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,230 @@
11
import { WebSocketServer, WebSocket } from 'ws';
2+
import { createHmac, randomBytes } from 'crypto';
3+
import jwt from 'jsonwebtoken';
4+
import logger from '../config/logger.js';
25

6+
// ── Config ────────────────────────────────────────────────────────────────────
7+
const MAX_CONNECTIONS_PER_KEY = 5;
8+
const MAX_QUEUE_SIZE = 100;
9+
const HEARTBEAT_INTERVAL_MS = 30_000;
10+
const MSG_ENCRYPTION_SECRET = process.env.WS_MSG_SECRET || randomBytes(32).toString('hex');
11+
12+
// ── State ─────────────────────────────────────────────────────────────────────
313
let wss = null;
414

5-
// Map of publicKey -> Set of ws clients subscribed to that account
15+
/** publicKey Set<ws> */
616
const subscriptions = new Map();
717

18+
/** publicKey → pending message queue (for offline/reconnect delivery) */
19+
const messageQueues = new Map();
20+
21+
/** Analytics counters */
22+
const stats = {
23+
totalConnections: 0,
24+
activeConnections: 0,
25+
messagesDelivered: 0,
26+
messagesQueued: 0,
27+
authFailures: 0,
28+
errors: 0,
29+
};
30+
31+
// ── Helpers ───────────────────────────────────────────────────────────────────
32+
33+
function signPayload(payload) {
34+
return createHmac('sha256', MSG_ENCRYPTION_SECRET)
35+
.update(typeof payload === 'string' ? payload : JSON.stringify(payload))
36+
.digest('hex');
37+
}
38+
39+
function buildEnvelope(payload) {
40+
const body = JSON.stringify(payload);
41+
const sig = signPayload(body);
42+
return JSON.stringify({ data: payload, sig });
43+
}
44+
45+
function verifyToken(token) {
46+
const secret = process.env.JWT_SECRET;
47+
if (!secret) return null;
48+
try {
49+
return jwt.verify(token, secret);
50+
} catch {
51+
return null;
52+
}
53+
}
54+
55+
function enqueue(publicKey, payload) {
56+
if (!messageQueues.has(publicKey)) messageQueues.set(publicKey, []);
57+
const q = messageQueues.get(publicKey);
58+
if (q.length >= MAX_QUEUE_SIZE) q.shift(); // drop oldest
59+
q.push(payload);
60+
stats.messagesQueued++;
61+
}
62+
63+
function flushQueue(publicKey, ws) {
64+
const q = messageQueues.get(publicKey);
65+
if (!q || q.length === 0) return;
66+
for (const payload of q) {
67+
if (ws.readyState === WebSocket.OPEN) {
68+
ws.send(buildEnvelope(payload));
69+
stats.messagesDelivered++;
70+
}
71+
}
72+
messageQueues.delete(publicKey);
73+
}
74+
75+
function connectionCount(publicKey) {
76+
return subscriptions.get(publicKey)?.size ?? 0;
77+
}
78+
79+
function removeClient(ws) {
80+
if (ws.subscribedKey) {
81+
subscriptions.get(ws.subscribedKey)?.delete(ws);
82+
if (subscriptions.get(ws.subscribedKey)?.size === 0) {
83+
subscriptions.delete(ws.subscribedKey);
84+
}
85+
}
86+
stats.activeConnections = Math.max(0, stats.activeConnections - 1);
87+
}
88+
89+
// ── Init ──────────────────────────────────────────────────────────────────────
90+
891
export function initWebSocket(server) {
992
wss = new WebSocketServer({ server });
1093

11-
wss.on('connection', (ws) => {
94+
wss.on('connection', (ws, req) => {
95+
stats.totalConnections++;
96+
stats.activeConnections++;
1297
ws.isAlive = true;
98+
ws.authenticated = false;
1399

14100
ws.on('pong', () => { ws.isAlive = true; });
15101

16102
ws.on('message', (raw) => {
17103
try {
18104
const msg = JSON.parse(raw);
19-
if (msg.type === 'subscribe' && msg.publicKey) {
20-
if (!subscriptions.has(msg.publicKey)) subscriptions.set(msg.publicKey, new Set());
21-
subscriptions.get(msg.publicKey).add(ws);
22-
ws.subscribedKey = msg.publicKey;
23-
ws.send(JSON.stringify({ type: 'subscribed', publicKey: msg.publicKey }));
24-
}
25-
} catch (_) {}
26-
});
27-
28-
ws.on('close', () => {
29-
if (ws.subscribedKey) {
30-
subscriptions.get(ws.subscribedKey)?.delete(ws);
105+
handleMessage(ws, msg);
106+
} catch {
107+
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
108+
stats.errors++;
31109
}
32110
});
33111

112+
ws.on('close', () => removeClient(ws));
113+
34114
ws.on('error', (err) => {
35-
console.error('WebSocket error:', err.message);
115+
logger.error('ws.error', { message: err.message });
116+
stats.errors++;
117+
removeClient(ws);
36118
});
37119
});
38120

39-
// Heartbeat to detect stale connections
40-
const interval = setInterval(() => {
121+
// Heartbeat detect and terminate stale connections
122+
const heartbeat = setInterval(() => {
41123
wss.clients.forEach((ws) => {
42-
if (!ws.isAlive) return ws.terminate();
124+
if (!ws.isAlive) {
125+
removeClient(ws);
126+
return ws.terminate();
127+
}
43128
ws.isAlive = false;
44129
ws.ping();
45130
});
46-
}, 30000);
131+
}, HEARTBEAT_INTERVAL_MS);
47132

48-
wss.on('close', () => clearInterval(interval));
133+
wss.on('close', () => clearInterval(heartbeat));
134+
135+
logger.info('ws.initialized');
136+
}
137+
138+
function handleMessage(ws, msg) {
139+
switch (msg.type) {
140+
case 'auth':
141+
return handleAuth(ws, msg);
142+
case 'subscribe':
143+
return handleSubscribe(ws, msg);
144+
case 'unsubscribe':
145+
return handleUnsubscribe(ws, msg);
146+
case 'ping':
147+
return ws.send(JSON.stringify({ type: 'pong' }));
148+
default:
149+
ws.send(JSON.stringify({ type: 'error', message: `Unknown message type: ${msg.type}` }));
150+
}
49151
}
50152

153+
function handleAuth(ws, msg) {
154+
const jwtSecret = process.env.JWT_SECRET;
155+
// If no JWT_SECRET configured, allow unauthenticated (dev mode)
156+
if (!jwtSecret) {
157+
ws.authenticated = true;
158+
ws.send(JSON.stringify({ type: 'auth_ok' }));
159+
return;
160+
}
161+
const claims = verifyToken(msg.token);
162+
if (!claims) {
163+
stats.authFailures++;
164+
ws.send(JSON.stringify({ type: 'auth_error', message: 'Invalid or expired token' }));
165+
return;
166+
}
167+
ws.authenticated = true;
168+
ws.userId = claims.sub ?? claims.userId;
169+
ws.send(JSON.stringify({ type: 'auth_ok' }));
170+
}
171+
172+
function handleSubscribe(ws, msg) {
173+
if (process.env.JWT_SECRET && !ws.authenticated) {
174+
ws.send(JSON.stringify({ type: 'error', message: 'Authenticate first' }));
175+
return;
176+
}
177+
const { publicKey } = msg;
178+
if (!publicKey) {
179+
ws.send(JSON.stringify({ type: 'error', message: 'publicKey required' }));
180+
return;
181+
}
182+
if (connectionCount(publicKey) >= MAX_CONNECTIONS_PER_KEY) {
183+
ws.send(JSON.stringify({ type: 'error', message: 'Connection limit reached for this account' }));
184+
return;
185+
}
186+
if (!subscriptions.has(publicKey)) subscriptions.set(publicKey, new Set());
187+
subscriptions.get(publicKey).add(ws);
188+
ws.subscribedKey = publicKey;
189+
ws.send(JSON.stringify({ type: 'subscribed', publicKey }));
190+
// Deliver any queued messages
191+
flushQueue(publicKey, ws);
192+
}
193+
194+
function handleUnsubscribe(ws, msg) {
195+
const key = msg.publicKey ?? ws.subscribedKey;
196+
if (key) subscriptions.get(key)?.delete(ws);
197+
ws.subscribedKey = null;
198+
ws.send(JSON.stringify({ type: 'unsubscribed' }));
199+
}
200+
201+
// ── Public API ────────────────────────────────────────────────────────────────
202+
203+
/**
204+
* Broadcast a payload to all subscribers of a publicKey.
205+
* If no subscribers are connected, the message is queued for later delivery.
206+
*/
51207
export function broadcastToAccount(publicKey, payload) {
52208
const clients = subscriptions.get(publicKey);
53-
if (!clients) return;
54-
const msg = JSON.stringify(payload);
209+
if (!clients || clients.size === 0) {
210+
enqueue(publicKey, payload);
211+
return;
212+
}
213+
const envelope = buildEnvelope(payload);
55214
clients.forEach((ws) => {
56-
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
215+
if (ws.readyState === WebSocket.OPEN) {
216+
ws.send(envelope);
217+
stats.messagesDelivered++;
218+
}
57219
});
58220
}
221+
222+
/** Returns live WebSocket analytics for monitoring dashboards. */
223+
export function getWsStats() {
224+
return {
225+
...stats,
226+
subscribedAccounts: subscriptions.size,
227+
queuedAccounts: messageQueues.size,
228+
totalQueued: [...messageQueues.values()].reduce((s, q) => s + q.length, 0),
229+
};
230+
}

0 commit comments

Comments
 (0)