@mooncompany/uplink-chat 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * WebSocket Connections Module
3
+ * Client tracking, rate limiting, heartbeats, IP-based limits, cleanup
4
+ */
5
+
6
+ import { WebSocket } from 'ws';
7
+ import { randomUUID } from 'crypto';
8
+ import { log } from '../utils.js';
9
+ import { WEBSOCKET } from '../config.js';
10
+
11
+ // ============================================
12
+ // Shared State
13
+ // ============================================
14
+
15
+ /** Track connected clients: clientId -> { ws, lastPing, lastPong, missedPongs, sessionUser, connectedAt, clientIp } */
16
+ export const wsClients = new Map();
17
+
18
+ /** Rate limiting per client: clientId -> { count, resetAt } */
19
+ const wsRateLimits = new Map();
20
+
21
+ /** IP-based connection tracking: ip -> Set<clientId> */
22
+ const wsConnectionsByIp = new Map();
23
+
24
+ /** Active streaming requests: requestId -> { abortController, clientId, startedAt } */
25
+ export const activeStreamingRequests = new Map();
26
+
27
+ // ============================================
28
+ // Config Constants
29
+ // ============================================
30
+
31
+ const WS_RATE_LIMIT = {
32
+ windowMs: WEBSOCKET.messageRateLimitWindow,
33
+ maxMessages: WEBSOCKET.maxMessagesPerWindow,
34
+ };
35
+ const MAX_WS_RATE_LIMIT_ENTRIES = WEBSOCKET.maxRateLimitEntries;
36
+ const MAX_ACTIVE_STREAMING_REQUESTS = WEBSOCKET.maxStreamingRequests;
37
+ export const WS_CONNECTION_LIMITS = WEBSOCKET.connectionLimits;
38
+ export const MAX_MESSAGE_SIZE = WEBSOCKET.maxMessageSize;
39
+ const HEARTBEAT_INTERVAL_MS = WEBSOCKET.heartbeatIntervalMs;
40
+ const MAX_MISSED_PONGS = WEBSOCKET.maxMissedPongs;
41
+
42
+ // ============================================
43
+ // Client ID Generation
44
+ // ============================================
45
+
46
+ export function generateClientId() {
47
+ return `ws-${Date.now()}-${randomUUID().slice(0, 8)}`;
48
+ }
49
+
50
+ // ============================================
51
+ // Safe Send Helper
52
+ // ============================================
53
+
54
+ /**
55
+ * Safely send a message to a WebSocket, checking readyState first
56
+ * Returns true if message was sent, false if connection was not open
57
+ */
58
+ export function safeSend(ws, data) {
59
+ if (ws.readyState === WebSocket.OPEN) {
60
+ try {
61
+ ws.send(typeof data === 'string' ? data : JSON.stringify(data));
62
+ return true;
63
+ } catch (sendError) {
64
+ log('warn', `[WS] Failed to send message:`, sendError.message);
65
+ return false;
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+
71
+ // ============================================
72
+ // Rate Limiting
73
+ // ============================================
74
+
75
+ /**
76
+ * Trim oldest rate limit entries when map exceeds size limit
77
+ * Removes entries that are expired first, then oldest by resetAt
78
+ */
79
+ function trimRateLimitEntries() {
80
+ if (wsRateLimits.size <= MAX_WS_RATE_LIMIT_ENTRIES) return;
81
+
82
+ const now = Date.now();
83
+ const entries = Array.from(wsRateLimits.entries());
84
+
85
+ // Sort by resetAt (oldest first)
86
+ entries.sort((a, b) => a[1].resetAt - b[1].resetAt);
87
+
88
+ const toRemove = wsRateLimits.size - MAX_WS_RATE_LIMIT_ENTRIES;
89
+ let removed = 0;
90
+
91
+ for (let i = 0; i < entries.length && removed < toRemove; i++) {
92
+ const [clientId] = entries[i];
93
+ // Only remove if client is disconnected OR entry is expired
94
+ if (!wsClients.has(clientId) || entries[i][1].resetAt < now) {
95
+ wsRateLimits.delete(clientId);
96
+ removed++;
97
+ }
98
+ }
99
+
100
+ // If still over limit, force remove oldest
101
+ if (wsRateLimits.size > MAX_WS_RATE_LIMIT_ENTRIES) {
102
+ const remaining = Array.from(wsRateLimits.entries())
103
+ .sort((a, b) => a[1].resetAt - b[1].resetAt);
104
+ const forceRemove = wsRateLimits.size - MAX_WS_RATE_LIMIT_ENTRIES;
105
+ for (let i = 0; i < forceRemove; i++) {
106
+ wsRateLimits.delete(remaining[i][0]);
107
+ }
108
+ }
109
+ }
110
+
111
+ export function checkWsRateLimit(clientId) {
112
+ const now = Date.now();
113
+ let limit = wsRateLimits.get(clientId);
114
+
115
+ if (!limit || now > limit.resetAt) {
116
+ limit = { count: 0, resetAt: now + WS_RATE_LIMIT.windowMs };
117
+ wsRateLimits.set(clientId, limit);
118
+ }
119
+
120
+ limit.count++;
121
+
122
+ // Ensure rate limit map doesn't grow unbounded
123
+ trimRateLimitEntries();
124
+
125
+ return limit.count <= WS_RATE_LIMIT.maxMessages;
126
+ }
127
+
128
+ /**
129
+ * Periodic cleanup of stale rate limit entries
130
+ * Removes entries that have expired and aren't associated with active clients
131
+ */
132
+ export function cleanupStaleRateLimits() {
133
+ const now = Date.now();
134
+ let cleaned = 0;
135
+
136
+ for (const [clientId, limit] of wsRateLimits) {
137
+ // Remove if: entry expired AND client no longer connected
138
+ if (now > limit.resetAt && !wsClients.has(clientId)) {
139
+ wsRateLimits.delete(clientId);
140
+ cleaned++;
141
+ }
142
+ }
143
+
144
+ if (cleaned > 0) {
145
+ log('debug', `[WS] Cleaned ${cleaned} stale rate limit entries`);
146
+ }
147
+ }
148
+
149
+ // ============================================
150
+ // Streaming Request Management
151
+ // ============================================
152
+
153
+ /**
154
+ * Trim oldest streaming requests when map exceeds size limit
155
+ * Aborts and removes oldest requests
156
+ */
157
+ export function trimStreamingRequests() {
158
+ if (activeStreamingRequests.size <= MAX_ACTIVE_STREAMING_REQUESTS) return;
159
+
160
+ const entries = Array.from(activeStreamingRequests.entries());
161
+ // Sort by startedAt (oldest first)
162
+ entries.sort((a, b) => (a[1].startedAt || 0) - (b[1].startedAt || 0));
163
+
164
+ const toRemove = activeStreamingRequests.size - MAX_ACTIVE_STREAMING_REQUESTS;
165
+
166
+ for (let i = 0; i < toRemove; i++) {
167
+ const [requestId, request] = entries[i];
168
+ try {
169
+ request.abortController.abort();
170
+ } catch (abortError) {
171
+ // Ignore abort errors
172
+ }
173
+ activeStreamingRequests.delete(requestId);
174
+ log('warn', `[WS] Evicted oldest streaming request ${requestId} due to size limit`);
175
+ }
176
+ }
177
+
178
+ // ============================================
179
+ // Connection Limits & IP Tracking
180
+ // ============================================
181
+
182
+ /**
183
+ * Check connection limits and return whether a new connection should be allowed
184
+ */
185
+ export function checkConnectionLimits(clientIp) {
186
+ // Check total connection limit
187
+ if (wsClients.size >= WS_CONNECTION_LIMITS.maxTotal) {
188
+ return { allowed: false, reason: 'Server at capacity' };
189
+ }
190
+
191
+ // Check per-IP limit
192
+ const existingConnections = wsConnectionsByIp.get(clientIp);
193
+ if (existingConnections && existingConnections.size >= WS_CONNECTION_LIMITS.maxPerIp) {
194
+ return { allowed: false, reason: 'Connection limit exceeded for this IP' };
195
+ }
196
+
197
+ return { allowed: true };
198
+ }
199
+
200
+ /**
201
+ * Track a new connection for an IP address
202
+ */
203
+ export function trackConnection(clientId, clientIp) {
204
+ if (!wsConnectionsByIp.has(clientIp)) {
205
+ wsConnectionsByIp.set(clientIp, new Set());
206
+ }
207
+ wsConnectionsByIp.get(clientIp).add(clientId);
208
+ }
209
+
210
+ /**
211
+ * Remove a connection from IP tracking
212
+ */
213
+ function untrackConnection(clientId, clientIp) {
214
+ const ipConnections = wsConnectionsByIp.get(clientIp);
215
+ if (ipConnections) {
216
+ ipConnections.delete(clientId);
217
+ if (ipConnections.size === 0) {
218
+ wsConnectionsByIp.delete(clientIp);
219
+ }
220
+ }
221
+ }
222
+
223
+ // ============================================
224
+ // Client Cleanup
225
+ // ============================================
226
+
227
+ /**
228
+ * Clean up all resources associated with a client
229
+ * Called on disconnect (close/error) to prevent memory leaks
230
+ * Safe to call multiple times — early-returns if already cleaned up (H-24)
231
+ *
232
+ * @param {string} clientId
233
+ * @param {string} clientIp
234
+ * @param {Function} cleanupSyncForClient - callback to clean sync delta throttles for this client
235
+ */
236
+ export function cleanupClient(clientId, clientIp, cleanupSyncForClient) {
237
+ // 1. Remove from clients map (guard against double-cleanup race condition)
238
+ const client = wsClients.get(clientId);
239
+ if (!client) return; // Already cleaned up
240
+ wsClients.delete(clientId);
241
+
242
+ // 2. Remove rate limit entry
243
+ wsRateLimits.delete(clientId);
244
+
245
+ // 3. Remove from IP tracking
246
+ if (clientIp) {
247
+ untrackConnection(clientId, clientIp);
248
+ }
249
+
250
+ // 4. Abort any active streaming requests for this client
251
+ let abortedCount = 0;
252
+ for (const [requestId, request] of activeStreamingRequests) {
253
+ if (request.clientId === clientId) {
254
+ try {
255
+ request.abortController.abort();
256
+ } catch (abortError) {
257
+ // Ignore abort errors
258
+ }
259
+ activeStreamingRequests.delete(requestId);
260
+ abortedCount++;
261
+ }
262
+ }
263
+
264
+ // 5. Clean up any sync delta throttles for this client
265
+ if (cleanupSyncForClient) {
266
+ cleanupSyncForClient(clientId);
267
+ }
268
+
269
+ // 6. Trim streaming requests map after cleanup
270
+ trimStreamingRequests();
271
+
272
+ log('debug', `[WS] Cleaned up client ${clientId}: rate limit removed, ${abortedCount} active requests aborted`);
273
+ }
274
+
275
+ // ============================================
276
+ // Heartbeat
277
+ // ============================================
278
+
279
+ /**
280
+ * Start server-initiated heartbeat interval
281
+ * Pings all connected clients and terminates those that miss too many pongs
282
+ * @param {Function} cleanupSyncForClient - callback to clean sync delta throttles
283
+ * @returns {NodeJS.Timeout} interval handle
284
+ */
285
+ export function startHeartbeat(cleanupSyncForClient) {
286
+ return setInterval(() => {
287
+ for (const [clientId, client] of wsClients) {
288
+ if (client.ws.readyState === WebSocket.OPEN) {
289
+ // Check if client missed too many pongs
290
+ if (client.missedPongs >= MAX_MISSED_PONGS) {
291
+ log('warn', `[WS] Client ${clientId} missed ${client.missedPongs} pongs, closing connection`);
292
+ client.ws.terminate();
293
+ cleanupClient(clientId, client.clientIp, cleanupSyncForClient);
294
+ continue;
295
+ }
296
+
297
+ // Increment missed pongs counter (will be reset when pong is received)
298
+ client.missedPongs++;
299
+
300
+ // Send ping
301
+ try {
302
+ client.ws.ping();
303
+ } catch (err) {
304
+ log('warn', `[WS] Failed to ping client ${clientId}:`, err.message);
305
+ }
306
+ }
307
+ }
308
+ }, HEARTBEAT_INTERVAL_MS);
309
+ }
310
+
311
+ /**
312
+ * Start periodic cleanup of dead connections (every 1 minute)
313
+ * @param {Function} cleanupSyncForClient - callback to clean sync delta throttles
314
+ * @returns {NodeJS.Timeout} interval handle
315
+ */
316
+ export function startDeadConnectionCleanup(cleanupSyncForClient) {
317
+ return setInterval(() => {
318
+ const now = Date.now();
319
+ const timeout = 5 * 60 * 1000;
320
+
321
+ for (const [clientId, client] of wsClients) {
322
+ if (now - client.lastPing > timeout) {
323
+ log('info', `[WS] Cleaning up stale connection: ${clientId}`);
324
+ client.ws.terminate();
325
+ cleanupClient(clientId, client.clientIp, cleanupSyncForClient);
326
+ }
327
+ }
328
+ }, 60 * 1000);
329
+ }
330
+
331
+ /**
332
+ * Start periodic cleanup of stale rate limit entries (every 5 minutes)
333
+ * @returns {NodeJS.Timeout} interval handle
334
+ */
335
+ export function startRateLimitCleanup() {
336
+ return setInterval(() => {
337
+ cleanupStaleRateLimits();
338
+ }, 5 * 60 * 1000);
339
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * WebSocket Module - Real-time bidirectional communication
3
+ *
4
+ * Thin orchestrator: creates the WebSocket.Server, delegates to submodules
5
+ * for connection management, message routing, broadcasting, and sync.
6
+ */
7
+
8
+ import { WebSocketServer } from 'ws';
9
+ import { log } from '../utils.js';
10
+ import { ALLOWED_ORIGINS, SESSION_USER } from '../config.js';
11
+ import { verifyWebSocketToken, isAuthEnabled } from '../middleware/auth.js';
12
+
13
+ // Connections
14
+ import {
15
+ wsClients,
16
+ generateClientId,
17
+ safeSend,
18
+ checkWsRateLimit,
19
+ checkConnectionLimits,
20
+ trackConnection,
21
+ cleanupClient,
22
+ startHeartbeat,
23
+ startDeadConnectionCleanup,
24
+ startRateLimitCleanup,
25
+ MAX_MESSAGE_SIZE,
26
+ } from './connections.js';
27
+
28
+ // Routing
29
+ import { handleWebSocketMessage } from './routing.js';
30
+
31
+ // Sync
32
+ import { cleanupSyncForClient } from './sync.js';
33
+
34
+ // Re-export public API (same interface as the original websocket.js)
35
+ export { wsClients } from './connections.js';
36
+ export {
37
+ broadcast,
38
+ broadcastToAll,
39
+ sendToClient,
40
+ generateMessageId,
41
+ broadcastSyncMessage,
42
+ broadcastSyncThinking,
43
+ broadcastSyncTool,
44
+ broadcastSyncComplete,
45
+ broadcastOpenClawPush,
46
+ trackBroadcast,
47
+ } from './broadcast.js';
48
+ export {
49
+ broadcastSyncDelta,
50
+ cleanupSyncDeltaThrottle,
51
+ } from './sync.js';
52
+
53
+ // ============================================
54
+ // Origin Verification
55
+ // ============================================
56
+
57
+ function verifyWebSocketOrigin(origin) {
58
+ // Reject null/undefined origins (L-02: WebSocket origin validation)
59
+ if (!origin) return false;
60
+ try {
61
+ const originUrl = new URL(origin);
62
+ // Allow localhost, configured origins, and Tailscale domains
63
+ return ALLOWED_ORIGINS.some(allowed => {
64
+ const allowedUrl = new URL(allowed);
65
+ return originUrl.hostname === allowedUrl.hostname;
66
+ }) || originUrl.hostname === 'localhost'
67
+ || originUrl.hostname === '127.0.0.1'
68
+ || originUrl.hostname.endsWith('.ts.net');
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // ============================================
75
+ // Setup WebSocket Server
76
+ // ============================================
77
+
78
+ export function setupWebSocket(server, requestHelpers, saveMessageToSync) {
79
+ const wss = new WebSocketServer({
80
+ noServer: true,
81
+ maxPayload: MAX_MESSAGE_SIZE // 1MB limit
82
+ });
83
+
84
+ // Handle upgrade requests for /ws path (noServer mode to avoid
85
+ // aborting other WebSocket paths like /api/realtime and /gateway)
86
+ server.on('upgrade', (request, socket, head) => {
87
+ const { pathname } = new URL(request.url, `http://${request.headers.host}`);
88
+
89
+ if (pathname !== '/ws') return; // Let other handlers take it
90
+
91
+ const origin = request.headers.origin;
92
+ const isValidOrigin = verifyWebSocketOrigin(origin);
93
+ if (!isValidOrigin) {
94
+ log('warn', `[WS] Rejected connection from invalid origin: ${origin}`);
95
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
96
+ socket.destroy();
97
+ return;
98
+ }
99
+
100
+ const clientIp = request.socket.remoteAddress;
101
+ const limitCheck = checkConnectionLimits(clientIp);
102
+ if (!limitCheck.allowed) {
103
+ log('warn', `[WS] Rejected connection from ${clientIp}: ${limitCheck.reason}`);
104
+ socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
105
+ socket.destroy();
106
+ return;
107
+ }
108
+
109
+ if (isAuthEnabled()) {
110
+ const url = new URL(request.url, `http://${request.headers.host}`);
111
+ const queryToken = url.searchParams.get('token');
112
+ const authHeader = request.headers.authorization;
113
+ const token = queryToken || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : authHeader);
114
+
115
+ if (!verifyWebSocketToken(token)) {
116
+ log('warn', `[WS] Rejected connection from ${clientIp}: invalid or missing auth token`);
117
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
118
+ socket.destroy();
119
+ return;
120
+ }
121
+ }
122
+
123
+ wss.handleUpgrade(request, socket, head, (ws) => {
124
+ wss.emit('connection', ws, request);
125
+ });
126
+ });
127
+
128
+ wss.on('connection', (ws, req) => {
129
+ const clientId = generateClientId();
130
+ const clientIp = req.socket.remoteAddress;
131
+
132
+ // Track connection for IP-based limits
133
+ trackConnection(clientId, clientIp);
134
+
135
+ log('info', `[WS] Client connected: ${clientId} from ${clientIp}`);
136
+
137
+ wsClients.set(clientId, {
138
+ ws,
139
+ lastPing: Date.now(),
140
+ lastPong: Date.now(), // Track pong responses for server-initiated heartbeat
141
+ missedPongs: 0, // Count of missed pong responses
142
+ sessionUser: SESSION_USER,
143
+ connectedAt: Date.now(),
144
+ clientIp
145
+ });
146
+
147
+ // Handle pong responses from server-initiated pings
148
+ ws.on('pong', () => {
149
+ const client = wsClients.get(clientId);
150
+ if (client) {
151
+ client.lastPong = Date.now();
152
+ client.missedPongs = 0;
153
+ }
154
+ });
155
+
156
+ safeSend(ws, {
157
+ type: 'connected',
158
+ clientId,
159
+ timestamp: new Date().toISOString()
160
+ });
161
+
162
+ ws.on('message', async (data) => {
163
+ // Manual message size validation (defense in depth with maxPayload)
164
+ if (data.length > MAX_MESSAGE_SIZE) {
165
+ log('warn', `[WS] Message too large from ${clientId}: ${data.length} bytes`);
166
+ safeSend(ws, {
167
+ type: 'error',
168
+ error: `Message too large. Maximum size is ${MAX_MESSAGE_SIZE / 1024 / 1024}MB.`
169
+ });
170
+ return;
171
+ }
172
+
173
+ if (!checkWsRateLimit(clientId)) {
174
+ log('warn', `[WS] Rate limit exceeded for ${clientId}`);
175
+ safeSend(ws, {
176
+ type: 'error',
177
+ error: 'Rate limit exceeded. Please slow down.'
178
+ });
179
+ return;
180
+ }
181
+
182
+ try {
183
+ const message = JSON.parse(data.toString());
184
+ await handleWebSocketMessage(clientId, message, requestHelpers, saveMessageToSync);
185
+ } catch (err) {
186
+ log('error', `[WS] Error handling message from ${clientId}:`, err.message);
187
+ safeSend(ws, {
188
+ type: 'error',
189
+ error: 'Invalid message format'
190
+ });
191
+ }
192
+ });
193
+
194
+ ws.on('close', (code) => {
195
+ log('info', `[WS] Client disconnected: ${clientId} (code: ${code})`);
196
+ cleanupClient(clientId, clientIp, cleanupSyncForClient);
197
+ });
198
+
199
+ ws.on('error', (err) => {
200
+ log('error', `[WS] Error for ${clientId}:`, err.message);
201
+ cleanupClient(clientId, clientIp, cleanupSyncForClient);
202
+ });
203
+ });
204
+
205
+ // Server-initiated heartbeat (ping/pong)
206
+ startHeartbeat(cleanupSyncForClient);
207
+
208
+ // Periodic cleanup of dead connections (every 1 minute)
209
+ startDeadConnectionCleanup(cleanupSyncForClient);
210
+
211
+ // Periodic cleanup of stale rate limit entries (every 5 minutes)
212
+ startRateLimitCleanup();
213
+
214
+ return wss;
215
+ }