@in-the-loop-labs/pair-review 2.2.0 → 2.3.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.
@@ -2,19 +2,17 @@
2
2
  /**
3
3
  * Setup Routes
4
4
  *
5
- * Provides API endpoints for the auto-create review flow with SSE progress
6
- * updates. Supports both PR-based and local review setup.
5
+ * Provides API endpoints for the auto-create review flow with WebSocket
6
+ * progress updates. Supports both PR-based and local review setup.
7
7
  *
8
8
  * Endpoints:
9
9
  * - POST /api/setup/pr/:owner/:repo/:number - Start PR review setup
10
- * - GET /api/setup/pr/:owner/:repo/:number/progress - SSE progress for PR setup
11
10
  * - POST /api/setup/local - Start local review setup
12
- * - GET /api/setup/local/:setupId/progress - SSE progress for local setup
13
11
  */
14
12
 
15
13
  const express = require('express');
16
14
  const crypto = require('crypto');
17
- const { activeSetups, setupProgressClients, broadcastSetupProgress } = require('./shared');
15
+ const { activeSetups, broadcastSetupProgress } = require('./shared');
18
16
  const { setupPRReview } = require('../setup/pr-setup');
19
17
  const { setupLocalReview } = require('../setup/local-setup');
20
18
  const { getGitHubToken } = require('../config');
@@ -25,31 +23,17 @@ const logger = require('../utils/logger');
25
23
  const router = express.Router();
26
24
 
27
25
  /**
28
- * Send a named SSE event to all clients listening for a given setupId.
26
+ * Send a setup progress event via WebSocket.
29
27
  *
30
- * Unlike broadcastSetupProgress (which sends unnamed `data:` lines), this
31
- * helper emits SSE named events (`event: <type>\n`) so the frontend
32
- * EventSource `addEventListener(type, ...)` listeners fire correctly.
28
+ * Converts the named event pattern to a WebSocket message with a `type`
29
+ * field so the client can dispatch on `msg.type` (e.g. 'step', 'complete', 'error').
33
30
  *
34
31
  * @param {string} setupId - Setup operation ID
35
- * @param {string} eventType - SSE event name (e.g. 'step', 'complete', 'error')
32
+ * @param {string} eventType - Event type (e.g. 'step', 'complete', 'error')
36
33
  * @param {Object} data - JSON-serialisable payload
37
34
  */
38
- function sendSetupSSE(setupId, eventType, data) {
39
- const clients = setupProgressClients.get(setupId);
40
- if (clients && clients.size > 0) {
41
- const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
42
- clients.forEach(client => {
43
- try {
44
- client.write(message);
45
- } catch (e) {
46
- clients.delete(client);
47
- }
48
- });
49
- if (clients.size === 0) {
50
- setupProgressClients.delete(setupId);
51
- }
52
- }
35
+ function sendSetupEvent(setupId, eventType, data) {
36
+ broadcastSetupProgress(setupId, { type: eventType, ...data });
53
37
  }
54
38
 
55
39
  // ---------------------------------------------------------------------------
@@ -60,7 +44,7 @@ function sendSetupSSE(setupId, eventType, data) {
60
44
  * Initiate an asynchronous PR review setup.
61
45
  *
62
46
  * Returns immediately with a { setupId } that the client uses to subscribe
63
- * to SSE progress events. If setup is already in-flight for this PR, the
47
+ * to WebSocket progress events. If setup is already in-flight for this PR, the
64
48
  * existing setupId is returned. If the PR already exists in the database the
65
49
  * response includes `{ existing: true, reviewUrl }` so the client can
66
50
  * navigate directly.
@@ -125,14 +109,14 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
125
109
  githubToken,
126
110
  config,
127
111
  onProgress: (progress) => {
128
- sendSetupSSE(setupId, 'step', progress);
112
+ sendSetupEvent(setupId, 'step', progress);
129
113
  }
130
114
  });
131
115
 
132
- sendSetupSSE(setupId, 'complete', { reviewUrl: result.reviewUrl, title: result.title });
116
+ sendSetupEvent(setupId, 'complete', { reviewUrl: result.reviewUrl, title: result.title });
133
117
  } catch (err) {
134
118
  logger.error(`PR setup failed for ${setupKey}:`, err);
135
- sendSetupSSE(setupId, 'error', { message: err.message });
119
+ sendSetupEvent(setupId, 'error', { message: err.message });
136
120
  } finally {
137
121
  activeSetups.delete(setupKey);
138
122
  }
@@ -147,48 +131,6 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
147
131
  }
148
132
  });
149
133
 
150
- // ---------------------------------------------------------------------------
151
- // GET /api/setup/pr/:owner/:repo/:number/progress
152
- // ---------------------------------------------------------------------------
153
-
154
- /**
155
- * SSE endpoint for streaming PR setup progress to the client.
156
- *
157
- * The client must pass `?setupId=<id>` as a query parameter (returned from
158
- * the POST endpoint above).
159
- */
160
- router.get('/api/setup/pr/:owner/:repo/:number/progress', (req, res) => {
161
- const { setupId } = req.query;
162
-
163
- if (!setupId) {
164
- return res.status(400).json({ error: 'Missing setupId query parameter' });
165
- }
166
-
167
- // SSE headers
168
- res.writeHead(200, {
169
- 'Content-Type': 'text/event-stream',
170
- 'Cache-Control': 'no-cache',
171
- 'Connection': 'keep-alive'
172
- });
173
-
174
- // Register this client
175
- if (!setupProgressClients.has(setupId)) {
176
- setupProgressClients.set(setupId, new Set());
177
- }
178
- setupProgressClients.get(setupId).add(res);
179
-
180
- // Clean up on disconnect
181
- req.on('close', () => {
182
- const clients = setupProgressClients.get(setupId);
183
- if (clients) {
184
- clients.delete(res);
185
- if (clients.size === 0) {
186
- setupProgressClients.delete(setupId);
187
- }
188
- }
189
- });
190
- });
191
-
192
134
  // ---------------------------------------------------------------------------
193
135
  // POST /api/setup/local
194
136
  // ---------------------------------------------------------------------------
@@ -197,7 +139,7 @@ router.get('/api/setup/pr/:owner/:repo/:number/progress', (req, res) => {
197
139
  * Initiate an asynchronous local review setup.
198
140
  *
199
141
  * Expects JSON body `{ path }` with the local directory to review. Returns
200
- * `{ setupId }` for the client to subscribe to SSE progress events.
142
+ * `{ setupId }` for the client to subscribe to WebSocket progress events.
201
143
  */
202
144
  router.post('/api/setup/local', async (req, res) => {
203
145
  try {
@@ -224,11 +166,11 @@ router.post('/api/setup/local', async (req, res) => {
224
166
  db,
225
167
  targetPath,
226
168
  onProgress: (progress) => {
227
- sendSetupSSE(setupId, 'step', progress);
169
+ sendSetupEvent(setupId, 'step', progress);
228
170
  }
229
171
  });
230
172
 
231
- sendSetupSSE(setupId, 'complete', {
173
+ sendSetupEvent(setupId, 'complete', {
232
174
  reviewUrl: result.reviewUrl,
233
175
  reviewId: result.reviewId,
234
176
  existing: result.existing,
@@ -237,7 +179,7 @@ router.post('/api/setup/local', async (req, res) => {
237
179
  });
238
180
  } catch (err) {
239
181
  logger.error(`Local setup failed for ${setupKey}:`, err);
240
- sendSetupSSE(setupId, 'error', { message: err.message });
182
+ sendSetupEvent(setupId, 'error', { message: err.message });
241
183
  } finally {
242
184
  activeSetups.delete(setupKey);
243
185
  }
@@ -252,43 +194,4 @@ router.post('/api/setup/local', async (req, res) => {
252
194
  }
253
195
  });
254
196
 
255
- // ---------------------------------------------------------------------------
256
- // GET /api/setup/local/:setupId/progress
257
- // ---------------------------------------------------------------------------
258
-
259
- /**
260
- * SSE endpoint for streaming local review setup progress to the client.
261
- */
262
- router.get('/api/setup/local/:setupId/progress', (req, res) => {
263
- const { setupId } = req.params;
264
-
265
- if (!setupId) {
266
- return res.status(400).json({ error: 'Missing setupId parameter' });
267
- }
268
-
269
- // SSE headers
270
- res.writeHead(200, {
271
- 'Content-Type': 'text/event-stream',
272
- 'Cache-Control': 'no-cache',
273
- 'Connection': 'keep-alive'
274
- });
275
-
276
- // Register this client
277
- if (!setupProgressClients.has(setupId)) {
278
- setupProgressClients.set(setupId, new Set());
279
- }
280
- setupProgressClients.get(setupId).add(res);
281
-
282
- // Clean up on disconnect
283
- req.on('close', () => {
284
- const clients = setupProgressClients.get(setupId);
285
- if (clients) {
286
- clients.delete(res);
287
- if (clients.size === 0) {
288
- setupProgressClients.delete(setupId);
289
- }
290
- }
291
- });
292
- });
293
-
294
197
  module.exports = router;
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const logger = require('../utils/logger');
10
+ const ws = require('../ws');
10
11
 
11
12
  /**
12
13
  * Custom error class for analysis cancellation
@@ -27,9 +28,6 @@ const activeAnalyses = new Map();
27
28
  // Unified map: replaces the previous separate prToAnalysisId and localReviewToAnalysisId maps.
28
29
  const reviewToAnalysisId = new Map();
29
30
 
30
- // Store SSE clients for real-time progress updates
31
- const progressClients = new Map();
32
-
33
31
  // Store local review diff data keyed by reviewId
34
32
  // Using a Map avoids process.env size limits and security concerns
35
33
  const localReviewDiffs = new Map();
@@ -42,10 +40,6 @@ const activeProcesses = new Map();
42
40
  // Maps setupKey (e.g., "pr:owner/repo/123" or "local:/path") -> { setupId, promise }
43
41
  const activeSetups = new Map();
44
42
 
45
- // Store SSE clients for setup progress updates
46
- // Maps setupId -> Set of response objects
47
- const setupProgressClients = new Map();
48
-
49
43
  /**
50
44
  * Get the model to use for AI analysis
51
45
  * Priority: CLI flag (PAIR_REVIEW_MODEL env var) > config.default_model > 'opus' default
@@ -115,33 +109,12 @@ function determineCompletionInfo(result) {
115
109
  }
116
110
 
117
111
  /**
118
- * Broadcast progress update to all connected SSE clients
112
+ * Broadcast progress update to all WebSocket clients subscribed to `analysis:{analysisId}`.
119
113
  * @param {string} analysisId - Analysis ID
120
114
  * @param {Object} progressData - Progress data to broadcast
121
115
  */
122
116
  function broadcastProgress(analysisId, progressData) {
123
- const clients = progressClients.get(analysisId);
124
- if (clients && clients.size > 0) {
125
- const message = `data: ${JSON.stringify({
126
- type: 'progress',
127
- ...progressData
128
- })}\n\n`;
129
-
130
- // Send to all connected clients
131
- clients.forEach(client => {
132
- try {
133
- client.write(message);
134
- } catch (error) {
135
- // Remove dead clients
136
- clients.delete(client);
137
- }
138
- });
139
-
140
- // Clean up if no clients left
141
- if (clients.size === 0) {
142
- progressClients.delete(analysisId);
143
- }
144
- }
117
+ ws.broadcast('analysis:' + analysisId, { type: 'progress', ...progressData });
145
118
  }
146
119
 
147
120
  /**
@@ -206,27 +179,12 @@ function isAnalysisCancelled(analysisId) {
206
179
  }
207
180
 
208
181
  /**
209
- * Broadcast setup progress to all connected SSE clients for a given setupId
182
+ * Broadcast setup progress to all WebSocket clients subscribed to `setup:{setupId}`.
210
183
  * @param {string} setupId - Setup operation ID
211
184
  * @param {Object} data - Progress data to broadcast
212
185
  */
213
186
  function broadcastSetupProgress(setupId, data) {
214
- const clients = setupProgressClients.get(setupId);
215
- if (clients && clients.size > 0) {
216
- const message = `data: ${JSON.stringify(data)}\n\n`;
217
-
218
- clients.forEach(client => {
219
- try {
220
- client.write(message);
221
- } catch (error) {
222
- clients.delete(client);
223
- }
224
- });
225
-
226
- if (clients.size === 0) {
227
- setupProgressClients.delete(setupId);
228
- }
229
- }
187
+ ws.broadcast('setup:' + setupId, data);
230
188
  }
231
189
 
232
190
  /**
@@ -426,11 +384,9 @@ module.exports = {
426
384
  CancellationError,
427
385
  activeAnalyses,
428
386
  reviewToAnalysisId,
429
- progressClients,
430
387
  localReviewDiffs,
431
388
  activeProcesses,
432
389
  activeSetups,
433
- setupProgressClients,
434
390
  getModel,
435
391
  determineCompletionInfo,
436
392
  broadcastProgress,
package/src/server.js CHANGED
@@ -6,6 +6,7 @@ const { initializeDatabase, getDatabaseStatus, queryOne, run } = require('./data
6
6
  const { normalizeRepository } = require('./utils/paths');
7
7
  const { applyConfigOverrides, checkAllProviders } = require('./ai');
8
8
  const logger = require('./utils/logger');
9
+ const { attachWebSocket, closeAll: closeAllWS } = require('./ws');
9
10
 
10
11
  let db = null;
11
12
  let server = null;
@@ -305,6 +306,7 @@ async function startServer(sharedDb = null) {
305
306
 
306
307
  server = app.listen(port, () => {
307
308
  console.log(`Server running on http://localhost:${port}`);
309
+ attachWebSocket(server);
308
310
  });
309
311
 
310
312
  server.on('error', (error) => {
@@ -343,6 +345,8 @@ async function gracefulShutdown(signal) {
343
345
  }
344
346
  }
345
347
 
348
+ closeAllWS();
349
+
346
350
  if (server) {
347
351
  server.close(() => {
348
352
  if (db) {
@@ -0,0 +1,137 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Shared comment formatter for adopted AI suggestions.
4
+ * Provides configurable formatting with preset templates.
5
+ */
6
+
7
+ const { getEmoji } = require('./category-emoji');
8
+
9
+ /**
10
+ * Preset format templates for adopted comments.
11
+ * Template placeholders: {emoji}, {category}, {title}, {description}, {suggestion}
12
+ * Conditional sections: {?field}...{/field} — content is kept when field is truthy, stripped when falsy.
13
+ */
14
+ const PRESETS = {
15
+ legacy: '{emoji} **{category}**: {description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}',
16
+ minimal: '[{category}] {description}{?suggestion}\n\n{suggestion}{/suggestion}',
17
+ plain: '{description}{?suggestion}\n\n{suggestion}{/suggestion}',
18
+ 'emoji-only': '{emoji} {description}{?suggestion}\n\n{suggestion}{/suggestion}',
19
+ maximal: '{emoji} **{category}**{?title}: {title}{/title}\n\n{description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}'
20
+ };
21
+
22
+ /**
23
+ * Resolve a config value into a format configuration object.
24
+ * @param {string|Object|undefined} config - Preset name string, custom config object, or undefined
25
+ * @returns {{ template: string, emojiOverrides: Object, categoryOverrides: Object }}
26
+ */
27
+ function resolveFormat(config) {
28
+ if (!config || typeof config === 'string') {
29
+ const presetName = config || 'legacy';
30
+ const template = PRESETS[presetName] || PRESETS.legacy;
31
+ return { template, emojiOverrides: {}, categoryOverrides: {} };
32
+ }
33
+
34
+ // Custom object config
35
+ return {
36
+ template: config.template || PRESETS.legacy,
37
+ emojiOverrides: config.emojiOverrides || {},
38
+ categoryOverrides: config.categoryOverrides || {}
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Capitalize a hyphenated category name.
44
+ * e.g., 'code-style' -> 'Code Style', 'bug' -> 'Bug'
45
+ * @param {string} category
46
+ * @returns {string}
47
+ */
48
+ function capitalizeCategory(category) {
49
+ return category
50
+ .split('-')
51
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
52
+ .join(' ');
53
+ }
54
+
55
+ /**
56
+ * Process conditional sections in a template.
57
+ * Syntax: {?fieldName}content{/fieldName}
58
+ * When the field value is truthy, the delimiters are stripped and content is kept.
59
+ * When the field value is falsy/empty/undefined, the entire block is removed.
60
+ *
61
+ * @param {string} template - Template with conditional sections
62
+ * @param {Object} values - Map of field names to their values
63
+ * @returns {string} Template with conditional sections resolved
64
+ */
65
+ function processConditionalSections(template, values) {
66
+ return template.replace(/\{\?(\w+)\}([\s\S]*?)\{\/\1\}/g, (match, fieldName, content) => {
67
+ const value = values[fieldName];
68
+ if (value !== undefined && value !== null && value !== '') {
69
+ return content;
70
+ }
71
+ return '';
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Format an adopted comment using the given format configuration.
77
+ * Handles legacy data where suggestion_text was concatenated into body.
78
+ *
79
+ * @param {{ body: string, suggestionText?: string, category?: string, title?: string }} fields
80
+ * @param {{ template: string, emojiOverrides: Object, categoryOverrides: Object }} formatConfig
81
+ * @returns {string} Formatted comment text
82
+ */
83
+ function formatAdoptedComment(fields, formatConfig) {
84
+ const { body, title } = fields;
85
+ let { category, suggestionText } = fields;
86
+
87
+ if (!category) {
88
+ return body || '';
89
+ }
90
+
91
+ category = category.toLowerCase();
92
+
93
+ // Legacy handling: if no separate suggestionText, try to split from body
94
+ let description = body || '';
95
+ if (!suggestionText && description.includes('\n\n**Suggestion:** ')) {
96
+ const splitIndex = description.indexOf('\n\n**Suggestion:** ');
97
+ suggestionText = description.slice(splitIndex + '\n\n**Suggestion:** '.length);
98
+ description = description.slice(0, splitIndex);
99
+ }
100
+
101
+ const config = formatConfig || resolveFormat();
102
+
103
+ // Resolve emoji from original category BEFORE applying overrides,
104
+ // so overridden categories keep the original category's emoji
105
+ const emoji = config.emojiOverrides?.[category] || getEmoji(category);
106
+
107
+ // Apply category overrides (e.g., "bug" -> "defect")
108
+ if (config.categoryOverrides && config.categoryOverrides[category]) {
109
+ category = config.categoryOverrides[category];
110
+ }
111
+ const capitalizedCategory = capitalizeCategory(category);
112
+
113
+ // Process conditional sections first, then replace individual placeholders
114
+ const fieldValues = {
115
+ suggestion: suggestionText || '',
116
+ title: title || '',
117
+ emoji,
118
+ category: capitalizedCategory,
119
+ description
120
+ };
121
+
122
+ let result = processConditionalSections(config.template, fieldValues);
123
+
124
+ // Replace placeholders
125
+ result = result.replace(/\{emoji\}/g, emoji);
126
+ result = result.replace(/\{category\}/g, capitalizedCategory);
127
+ result = result.replace(/\{title\}/g, title || '');
128
+ result = result.replace(/\{description\}/g, description);
129
+ result = result.replace(/\{suggestion\}/g, suggestionText || '');
130
+
131
+ // Ensure code fences start on their own line
132
+ result = result.replace(/([^\n])(```)/g, '$1\n$2');
133
+
134
+ return result.trimEnd();
135
+ }
136
+
137
+ module.exports = { PRESETS, resolveFormat, formatAdoptedComment, capitalizeCategory, processConditionalSections };
@@ -0,0 +1,2 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ module.exports = require('./server');
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ const { WebSocketServer } = require('ws');
3
+ const logger = require('../utils/logger');
4
+
5
+ const HEARTBEAT_INTERVAL = 30000;
6
+
7
+ let wss = null;
8
+ let heartbeatTimer = null;
9
+
10
+ /**
11
+ * Attach a WebSocket server to an existing HTTP server.
12
+ * Operates in noServer mode, handling upgrade requests on the /ws path only.
13
+ * @param {import('http').Server} httpServer
14
+ */
15
+ function attachWebSocket(httpServer) {
16
+ wss = new WebSocketServer({ noServer: true });
17
+
18
+ httpServer.on('upgrade', (request, socket, head) => {
19
+ const { pathname } = new URL(request.url, `http://${request.headers.host}`);
20
+ if (pathname !== '/ws') {
21
+ socket.destroy();
22
+ return;
23
+ }
24
+
25
+ wss.handleUpgrade(request, socket, head, (ws) => {
26
+ wss.emit('connection', ws, request);
27
+ });
28
+ });
29
+
30
+ wss.on('connection', (ws) => {
31
+ ws._topics = new Set();
32
+ ws.isAlive = true;
33
+
34
+ ws.on('pong', () => {
35
+ ws.isAlive = true;
36
+ });
37
+
38
+ ws.on('message', (data) => {
39
+ let msg;
40
+ try {
41
+ msg = JSON.parse(data);
42
+ } catch {
43
+ logger.warn('WS: received non-JSON message');
44
+ return;
45
+ }
46
+
47
+ const { action, topic } = msg;
48
+ if (!topic) return;
49
+
50
+ if (action === 'subscribe') {
51
+ ws._topics.add(topic);
52
+ } else if (action === 'unsubscribe') {
53
+ ws._topics.delete(topic);
54
+ }
55
+ });
56
+
57
+ ws.on('close', () => {
58
+ ws._topics.clear();
59
+ });
60
+
61
+ ws.on('error', (err) => {
62
+ logger.warn(`WS: client error: ${err.message}`);
63
+ ws._topics.clear();
64
+ });
65
+ });
66
+
67
+ // Heartbeat: ping every HEARTBEAT_INTERVAL, terminate dead connections
68
+ heartbeatTimer = setInterval(() => {
69
+ wss.clients.forEach((ws) => {
70
+ if (!ws.isAlive) {
71
+ logger.debug('WS: terminating unresponsive client');
72
+ ws.terminate();
73
+ return;
74
+ }
75
+ ws.isAlive = false;
76
+ ws.ping();
77
+ });
78
+ }, HEARTBEAT_INTERVAL);
79
+
80
+ logger.info('WebSocket server attached on /ws');
81
+ }
82
+
83
+ /**
84
+ * Broadcast a payload to all clients subscribed to the given topic.
85
+ * @param {string} topic
86
+ * @param {object} payload
87
+ */
88
+ function broadcast(topic, payload) {
89
+ if (!wss) return;
90
+
91
+ const message = JSON.stringify({ ...payload, topic });
92
+
93
+ wss.clients.forEach((ws) => {
94
+ if (ws.readyState === ws.OPEN && ws._topics && ws._topics.has(topic)) {
95
+ try {
96
+ ws.send(message);
97
+ } catch (err) {
98
+ logger.debug(`WS: failed to send to client: ${err.message}`);
99
+ }
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Close all connections and shut down the WebSocket server.
106
+ */
107
+ function closeAll() {
108
+ if (heartbeatTimer) {
109
+ clearInterval(heartbeatTimer);
110
+ heartbeatTimer = null;
111
+ }
112
+
113
+ if (!wss) return;
114
+
115
+ wss.clients.forEach((ws) => {
116
+ ws.terminate();
117
+ });
118
+
119
+ wss.close();
120
+ wss = null;
121
+ }
122
+
123
+ module.exports = { attachWebSocket, broadcast, closeAll, get _wss() { return wss; } };
@@ -1,46 +0,0 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
2
- /**
3
- * Shared SSE client registry and review-scoped event broadcaster.
4
- *
5
- * All SSE connections (chat, analysis, etc.) share a single Set of
6
- * Express response objects. broadcastReviewEvent sends review-level
7
- * events (as opposed to session-level events handled in chat.js).
8
- */
9
-
10
- const logger = require('../utils/logger');
11
-
12
- /**
13
- * Connected SSE clients shared across all route modules.
14
- * Each entry is an Express response object with an open SSE connection.
15
- * @type {Set<import('express').Response>}
16
- */
17
- const sseClients = new Set();
18
-
19
- /**
20
- * Broadcast a review-scoped SSE event to all connected clients.
21
- * Optionally includes a `sourceClientId` so the originating browser tab
22
- * can recognise (and skip) its own echo.
23
- *
24
- * @param {number} reviewId - Review ID to include in the event
25
- * @param {Object} payload - Event data (must include at minimum a `type` field)
26
- * @param {Object} [options]
27
- * @param {string} [options.sourceClientId] - Client ID of the tab that triggered the mutation
28
- */
29
- function broadcastReviewEvent(reviewId, payload, options = {}) {
30
- const envelope = { ...payload, reviewId };
31
- if (options.sourceClientId) {
32
- envelope.sourceClientId = options.sourceClientId;
33
- }
34
- const data = JSON.stringify(envelope);
35
- for (const client of sseClients) {
36
- try {
37
- client.write(`data: ${data}\n\n`);
38
- } catch {
39
- // Client disconnected — remove from set
40
- sseClients.delete(client);
41
- logger.debug('[ReviewEvents] Removed disconnected SSE client');
42
- }
43
- }
44
- }
45
-
46
- module.exports = { sseClients, broadcastReviewEvent };