|
1 | 1 | import { WebSocketServer, WebSocket } from 'ws'; |
| 2 | +import { createHmac, randomBytes } from 'crypto'; |
| 3 | +import jwt from 'jsonwebtoken'; |
| 4 | +import logger from '../config/logger.js'; |
2 | 5 |
|
| 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 ───────────────────────────────────────────────────────────────────── |
3 | 13 | let wss = null; |
4 | 14 |
|
5 | | -// Map of publicKey -> Set of ws clients subscribed to that account |
| 15 | +/** publicKey → Set<ws> */ |
6 | 16 | const subscriptions = new Map(); |
7 | 17 |
|
| 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 | + |
8 | 91 | export function initWebSocket(server) { |
9 | 92 | wss = new WebSocketServer({ server }); |
10 | 93 |
|
11 | | - wss.on('connection', (ws) => { |
| 94 | + wss.on('connection', (ws, req) => { |
| 95 | + stats.totalConnections++; |
| 96 | + stats.activeConnections++; |
12 | 97 | ws.isAlive = true; |
| 98 | + ws.authenticated = false; |
13 | 99 |
|
14 | 100 | ws.on('pong', () => { ws.isAlive = true; }); |
15 | 101 |
|
16 | 102 | ws.on('message', (raw) => { |
17 | 103 | try { |
18 | 104 | 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++; |
31 | 109 | } |
32 | 110 | }); |
33 | 111 |
|
| 112 | + ws.on('close', () => removeClient(ws)); |
| 113 | + |
34 | 114 | 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); |
36 | 118 | }); |
37 | 119 | }); |
38 | 120 |
|
39 | | - // Heartbeat to detect stale connections |
40 | | - const interval = setInterval(() => { |
| 121 | + // Heartbeat — detect and terminate stale connections |
| 122 | + const heartbeat = setInterval(() => { |
41 | 123 | wss.clients.forEach((ws) => { |
42 | | - if (!ws.isAlive) return ws.terminate(); |
| 124 | + if (!ws.isAlive) { |
| 125 | + removeClient(ws); |
| 126 | + return ws.terminate(); |
| 127 | + } |
43 | 128 | ws.isAlive = false; |
44 | 129 | ws.ping(); |
45 | 130 | }); |
46 | | - }, 30000); |
| 131 | + }, HEARTBEAT_INTERVAL_MS); |
47 | 132 |
|
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 | + } |
49 | 151 | } |
50 | 152 |
|
| 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 | + */ |
51 | 207 | export function broadcastToAccount(publicKey, payload) { |
52 | 208 | 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); |
55 | 214 | 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 | + } |
57 | 219 | }); |
58 | 220 | } |
| 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