vibemon 1.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.
@@ -0,0 +1,584 @@
1
+ /**
2
+ * HTTP server for Vibe Monitor (Multi-Window)
3
+ */
4
+
5
+ const http = require('http');
6
+ const fsPromises = require('fs').promises;
7
+ const path = require('path');
8
+ const { HTTP_PORT, MAX_PAYLOAD_SIZE, MAX_WINDOWS, STATS_CACHE_PATH } = require('../shared/config.cjs');
9
+ const { setCorsHeaders, sendJson, sendError, parseJsonBody } = require('./http-utils.cjs');
10
+ const { validateStatusPayload } = require('./validators.cjs');
11
+
12
+ // Rate limiting configuration
13
+ const RATE_LIMIT = 100; // Max requests per window
14
+ const RATE_WINDOW_MS = 60000; // 1 minute window
15
+ const RATE_CLEANUP_THRESHOLD = 100; // Cleanup when map exceeds this size
16
+
17
+ class HttpServer {
18
+ constructor(stateManager, windowManager, app) {
19
+ this.server = null;
20
+ this.stateManager = stateManager;
21
+ this.windowManager = windowManager;
22
+ this.app = app;
23
+ this.onStateUpdate = null; // Callback for menu/icon updates
24
+ this.onError = null; // Callback for server errors
25
+
26
+ // Rate limiting state
27
+ this.requestCounts = new Map(); // IP -> { count, resetTime }
28
+ }
29
+
30
+ /**
31
+ * Cleanup expired rate limit entries to prevent memory leak
32
+ */
33
+ cleanupExpiredRateLimits() {
34
+ const now = Date.now();
35
+ for (const [ip, record] of this.requestCounts) {
36
+ if (now > record.resetTime) {
37
+ this.requestCounts.delete(ip);
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Check rate limit for an IP address
44
+ * @param {string} ip
45
+ * @returns {boolean} true if allowed, false if rate limited
46
+ */
47
+ checkRateLimit(ip) {
48
+ // Cleanup expired entries when map gets large
49
+ if (this.requestCounts.size > RATE_CLEANUP_THRESHOLD) {
50
+ this.cleanupExpiredRateLimits();
51
+ }
52
+
53
+ const now = Date.now();
54
+ const record = this.requestCounts.get(ip);
55
+
56
+ if (!record || now > record.resetTime) {
57
+ // New window or expired - reset counter
58
+ this.requestCounts.set(ip, { count: 1, resetTime: now + RATE_WINDOW_MS });
59
+ return true;
60
+ }
61
+
62
+ if (record.count >= RATE_LIMIT) {
63
+ return false; // Rate limited
64
+ }
65
+
66
+ record.count++;
67
+ return true;
68
+ }
69
+
70
+ start() {
71
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
72
+
73
+ this.server.on('error', (err) => {
74
+ console.error('HTTP Server error:', err.message);
75
+ if (err.code === 'EADDRINUSE') {
76
+ console.error(`Port ${HTTP_PORT} is already in use`);
77
+ }
78
+ // Notify error callback if registered
79
+ if (this.onError) {
80
+ this.onError(err);
81
+ }
82
+ });
83
+
84
+ this.server.listen(HTTP_PORT, '127.0.0.1', () => {
85
+ console.log(`Vibe Monitor HTTP server running on http://127.0.0.1:${HTTP_PORT}`);
86
+ });
87
+
88
+ return this.server;
89
+ }
90
+
91
+ stop() {
92
+ return new Promise((resolve) => {
93
+ // Clear rate limiting state
94
+ this.requestCounts.clear();
95
+
96
+ if (!this.server) {
97
+ resolve();
98
+ return;
99
+ }
100
+
101
+ const forceCloseTimeout = setTimeout(() => {
102
+ console.warn('HTTP server close timeout, forcing shutdown');
103
+ resolve();
104
+ }, 5000);
105
+
106
+ this.server.close((err) => {
107
+ clearTimeout(forceCloseTimeout);
108
+ if (err && err.code !== 'ERR_SERVER_NOT_RUNNING') {
109
+ console.error('HTTP server close error:', err.message);
110
+ }
111
+ this.server = null;
112
+ resolve();
113
+ });
114
+ });
115
+ }
116
+
117
+ async handleRequest(req, res) {
118
+ setCorsHeaders(res, req);
119
+
120
+ if (req.method === 'OPTIONS') {
121
+ res.writeHead(200);
122
+ res.end();
123
+ return;
124
+ }
125
+
126
+ // Rate limiting check
127
+ const ip = req.socket.remoteAddress || '127.0.0.1';
128
+ if (!this.checkRateLimit(ip)) {
129
+ sendError(res, 429, 'Too many requests');
130
+ return;
131
+ }
132
+
133
+ const route = `${req.method} ${req.url}`;
134
+
135
+ switch (route) {
136
+ case 'POST /status':
137
+ await this.handlePostStatus(req, res);
138
+ break;
139
+ case 'GET /status':
140
+ this.handleGetStatus(res);
141
+ break;
142
+ case 'GET /windows':
143
+ this.handleGetWindows(res);
144
+ break;
145
+ case 'POST /close':
146
+ await this.handlePostClose(req, res);
147
+ break;
148
+ case 'GET /health':
149
+ this.handleGetHealth(res);
150
+ break;
151
+ case 'POST /show':
152
+ await this.handlePostShow(req, res);
153
+ break;
154
+ case 'GET /debug':
155
+ this.handleGetDebug(res);
156
+ break;
157
+ case 'POST /quit':
158
+ this.handlePostQuit(res);
159
+ break;
160
+ case 'POST /lock':
161
+ await this.handlePostLock(req, res);
162
+ break;
163
+ case 'POST /unlock':
164
+ this.handlePostUnlock(res);
165
+ break;
166
+ case 'GET /lock-mode':
167
+ this.handleGetLockMode(res);
168
+ break;
169
+ case 'POST /lock-mode':
170
+ await this.handlePostLockMode(req, res);
171
+ break;
172
+ case 'GET /window-mode':
173
+ this.handleGetWindowMode(res);
174
+ break;
175
+ case 'POST /window-mode':
176
+ await this.handlePostWindowMode(req, res);
177
+ break;
178
+ case 'GET /stats':
179
+ await this.handleGetStatsPage(res);
180
+ break;
181
+ case 'GET /stats/data':
182
+ await this.handleGetStatsData(res);
183
+ break;
184
+ default:
185
+ res.writeHead(404);
186
+ res.end('Not Found');
187
+ }
188
+ }
189
+
190
+ async handlePostStatus(req, res) {
191
+ const { data, error, statusCode } = await parseJsonBody(req, MAX_PAYLOAD_SIZE);
192
+
193
+ if (error) {
194
+ sendError(res, statusCode, error);
195
+ return;
196
+ }
197
+
198
+ // Validate payload
199
+ const validation = validateStatusPayload(data);
200
+ if (!validation.valid) {
201
+ sendError(res, 400, validation.error);
202
+ return;
203
+ }
204
+
205
+ // Validate and normalize state data via stateManager
206
+ const stateValidation = this.stateManager.validateStateData(data);
207
+ if (!stateValidation.valid) {
208
+ sendError(res, 400, stateValidation.error || 'Invalid state data');
209
+ return;
210
+ }
211
+ const stateData = stateValidation.data; // Extract normalized data
212
+
213
+ // Get projectId from data or use default
214
+ let projectId = stateData.project || 'default';
215
+
216
+ // Create window if not exists
217
+ if (!this.windowManager.getWindow(projectId)) {
218
+ const result = this.windowManager.createWindow(projectId);
219
+
220
+ // Blocked by lock in single mode
221
+ if (result.blocked) {
222
+ sendJson(res, 200, {
223
+ success: false,
224
+ error: 'Project locked',
225
+ lockedProject: this.windowManager.getLockedProject()
226
+ });
227
+ return;
228
+ }
229
+
230
+ // No window created (max limit in multi mode)
231
+ if (!result.window) {
232
+ sendJson(res, 200, {
233
+ success: false,
234
+ error: `Maximum windows limit (${MAX_WINDOWS}) reached`,
235
+ windowCount: this.windowManager.getWindowCount()
236
+ });
237
+ return;
238
+ }
239
+
240
+ // Project was switched in single mode
241
+ if (result.switchedProject) {
242
+ // Clean up old project's timers
243
+ this.stateManager.cleanupProject(result.switchedProject);
244
+ }
245
+ }
246
+
247
+ // Apply auto-lock after window is successfully created (single mode only)
248
+ this.windowManager.applyAutoLock(projectId, stateData.state);
249
+
250
+ // Update window state via windowManager (with change detection)
251
+ const updateResult = this.windowManager.updateState(projectId, stateData);
252
+
253
+ // No change - skip unnecessary updates
254
+ if (!updateResult.updated) {
255
+ sendJson(res, 200, {
256
+ success: true,
257
+ project: projectId,
258
+ state: stateData.state,
259
+ windowCount: this.windowManager.getWindowCount(),
260
+ skipped: true
261
+ });
262
+ return;
263
+ }
264
+
265
+ // State changed - full update (alwaysOnTop, rearrange, timeout, tray)
266
+ if (updateResult.stateChanged) {
267
+ // Update always on top based on state (active states stay on top)
268
+ this.windowManager.updateAlwaysOnTopByState(projectId, stateData.state);
269
+
270
+ // Rearrange windows by state and name (active states on right)
271
+ this.windowManager.rearrangeWindows();
272
+
273
+ // Set up state timeout for this project
274
+ this.stateManager.setupStateTimeout(projectId, stateData.state);
275
+
276
+ // Update tray
277
+ if (this.onStateUpdate) {
278
+ this.onStateUpdate(false); // Full update
279
+ }
280
+ }
281
+
282
+ // Send update to renderer (for both state and info changes)
283
+ this.windowManager.sendToWindow(projectId, 'state-update', stateData);
284
+
285
+ sendJson(res, 200, {
286
+ success: true,
287
+ project: projectId,
288
+ state: stateData.state,
289
+ windowCount: this.windowManager.getWindowCount()
290
+ });
291
+ }
292
+
293
+ handleGetStatus(res) {
294
+ // Return all windows' states
295
+ const states = this.windowManager.getStates();
296
+ sendJson(res, 200, {
297
+ windowCount: this.windowManager.getWindowCount(),
298
+ projects: states
299
+ });
300
+ }
301
+
302
+ handleGetWindows(res) {
303
+ // List all active windows
304
+ const windows = this.windowManager.getWindows();
305
+ const windowList = Object.entries(windows).map(([projectId, windowInfo]) => ({
306
+ project: projectId,
307
+ state: windowInfo.state ? windowInfo.state.state : 'unknown',
308
+ bounds: windowInfo.window && !windowInfo.window.isDestroyed()
309
+ ? windowInfo.window.getBounds()
310
+ : null
311
+ }));
312
+
313
+ sendJson(res, 200, {
314
+ windowCount: windowList.length,
315
+ windows: windowList
316
+ });
317
+ }
318
+
319
+ async handlePostClose(req, res) {
320
+ const { data, error, statusCode } = await parseJsonBody(req, MAX_PAYLOAD_SIZE);
321
+
322
+ if (error) {
323
+ sendError(res, statusCode, error);
324
+ return;
325
+ }
326
+
327
+ const projectId = data.project;
328
+
329
+ if (!projectId) {
330
+ sendError(res, 400, 'Project is required');
331
+ return;
332
+ }
333
+
334
+ const closed = this.windowManager.closeWindow(projectId);
335
+
336
+ if (!closed) {
337
+ sendJson(res, 200, {
338
+ success: false,
339
+ error: `Window for project '${projectId}' not found`
340
+ });
341
+ return;
342
+ }
343
+
344
+ // Update tray
345
+ if (this.onStateUpdate) {
346
+ this.onStateUpdate(true); // Menu only
347
+ }
348
+
349
+ sendJson(res, 200, {
350
+ success: true,
351
+ project: projectId,
352
+ windowCount: this.windowManager.getWindowCount()
353
+ });
354
+ }
355
+
356
+ handleGetHealth(res) {
357
+ sendJson(res, 200, { status: 'ok' });
358
+ }
359
+
360
+ async handlePostShow(req, res) {
361
+ const { data, error, statusCode } = await parseJsonBody(req, MAX_PAYLOAD_SIZE);
362
+
363
+ if (error) {
364
+ sendError(res, statusCode, error);
365
+ return;
366
+ }
367
+
368
+ const projectId = data.project;
369
+
370
+ // Show specific project window or first window
371
+ const shown = projectId
372
+ ? this.windowManager.showWindow(projectId)
373
+ : this.windowManager.showFirstWindow();
374
+
375
+ sendJson(res, 200, {
376
+ success: shown,
377
+ project: projectId || 'first'
378
+ });
379
+ }
380
+
381
+ handleGetDebug(res) {
382
+ const debugInfo = this.windowManager.getDebugInfo();
383
+ res.writeHead(200, { 'Content-Type': 'application/json' });
384
+ res.end(JSON.stringify(debugInfo, null, 2));
385
+ }
386
+
387
+ handlePostQuit(res) {
388
+ sendJson(res, 200, { success: true });
389
+ setTimeout(() => this.app.quit(), 100);
390
+ }
391
+
392
+ async handlePostLock(req, res) {
393
+ const { data, error, statusCode } = await parseJsonBody(req, MAX_PAYLOAD_SIZE);
394
+
395
+ if (error) {
396
+ sendError(res, statusCode, error);
397
+ return;
398
+ }
399
+
400
+ const projectId = data.project;
401
+
402
+ if (!projectId) {
403
+ sendError(res, 400, 'Project is required');
404
+ return;
405
+ }
406
+
407
+ // Lock only works in single mode
408
+ if (this.windowManager.isMultiMode()) {
409
+ sendJson(res, 200, {
410
+ success: false,
411
+ error: 'Lock only available in single-window mode'
412
+ });
413
+ return;
414
+ }
415
+
416
+ // Check if project has an active window
417
+ const hasWindow = this.windowManager.hasWindow(projectId);
418
+ const locked = this.windowManager.lockProject(projectId);
419
+
420
+ // Update tray menu
421
+ if (this.onStateUpdate) {
422
+ this.onStateUpdate(true);
423
+ }
424
+
425
+ const response = {
426
+ success: locked,
427
+ lockedProject: this.windowManager.getLockedProject()
428
+ };
429
+
430
+ // Add warning if locking a project without active window
431
+ if (locked && !hasWindow) {
432
+ response.warning = 'No active window for this project';
433
+ }
434
+
435
+ sendJson(res, 200, response);
436
+ }
437
+
438
+ handlePostUnlock(res) {
439
+ // Unlock only works in single mode
440
+ if (this.windowManager.isMultiMode()) {
441
+ sendJson(res, 200, {
442
+ success: false,
443
+ error: 'Unlock only available in single-window mode'
444
+ });
445
+ return;
446
+ }
447
+
448
+ this.windowManager.unlockProject();
449
+
450
+ // Update tray menu
451
+ if (this.onStateUpdate) {
452
+ this.onStateUpdate(true);
453
+ }
454
+
455
+ sendJson(res, 200, {
456
+ success: true,
457
+ lockedProject: null
458
+ });
459
+ }
460
+
461
+ handleGetLockMode(res) {
462
+ sendJson(res, 200, {
463
+ mode: this.windowManager.getLockMode(),
464
+ modes: this.windowManager.getLockModes(),
465
+ lockedProject: this.windowManager.getLockedProject(),
466
+ windowMode: this.windowManager.getWindowMode()
467
+ });
468
+ }
469
+
470
+ async handlePostLockMode(req, res) {
471
+ const { data, error, statusCode } = await parseJsonBody(req, MAX_PAYLOAD_SIZE);
472
+
473
+ if (error) {
474
+ sendError(res, statusCode, error);
475
+ return;
476
+ }
477
+
478
+ const mode = data.mode;
479
+
480
+ if (!mode) {
481
+ sendError(res, 400, 'Mode is required');
482
+ return;
483
+ }
484
+
485
+ const success = this.windowManager.setLockMode(mode);
486
+
487
+ if (!success) {
488
+ sendJson(res, 200, {
489
+ success: false,
490
+ error: `Invalid mode: ${mode}`,
491
+ validModes: Object.keys(this.windowManager.getLockModes())
492
+ });
493
+ return;
494
+ }
495
+
496
+ // Update tray menu
497
+ if (this.onStateUpdate) {
498
+ this.onStateUpdate(true);
499
+ }
500
+
501
+ sendJson(res, 200, {
502
+ success: true,
503
+ mode: this.windowManager.getLockMode(),
504
+ lockedProject: this.windowManager.getLockedProject()
505
+ });
506
+ }
507
+
508
+ handleGetWindowMode(res) {
509
+ sendJson(res, 200, {
510
+ mode: this.windowManager.getWindowMode(),
511
+ windowCount: this.windowManager.getWindowCount(),
512
+ lockedProject: this.windowManager.getLockedProject()
513
+ });
514
+ }
515
+
516
+ async handlePostWindowMode(req, res) {
517
+ const { data, error, statusCode } = await parseJsonBody(req, MAX_PAYLOAD_SIZE);
518
+
519
+ if (error) {
520
+ sendError(res, statusCode, error);
521
+ return;
522
+ }
523
+
524
+ const mode = data.mode;
525
+
526
+ if (!mode) {
527
+ sendError(res, 400, 'Mode is required');
528
+ return;
529
+ }
530
+
531
+ if (mode !== 'multi' && mode !== 'single') {
532
+ sendJson(res, 200, {
533
+ success: false,
534
+ error: `Invalid mode: ${mode}`,
535
+ validModes: ['multi', 'single']
536
+ });
537
+ return;
538
+ }
539
+
540
+ this.windowManager.setWindowMode(mode);
541
+
542
+ // Update tray menu
543
+ if (this.onStateUpdate) {
544
+ this.onStateUpdate(true);
545
+ }
546
+
547
+ sendJson(res, 200, {
548
+ success: true,
549
+ mode: this.windowManager.getWindowMode(),
550
+ windowCount: this.windowManager.getWindowCount(),
551
+ lockedProject: this.windowManager.getLockedProject()
552
+ });
553
+ }
554
+
555
+ async handleGetStatsPage(res) {
556
+ const statsHtmlPath = path.join(__dirname, '..', 'stats.html');
557
+
558
+ try {
559
+ const html = await fsPromises.readFile(statsHtmlPath, 'utf8');
560
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
561
+ res.end(html);
562
+ } catch {
563
+ sendError(res, 500, 'Failed to load stats page');
564
+ }
565
+ }
566
+
567
+ async handleGetStatsData(res) {
568
+ try {
569
+ const data = await fsPromises.readFile(STATS_CACHE_PATH, 'utf8');
570
+ const stats = JSON.parse(data);
571
+ sendJson(res, 200, stats);
572
+ } catch (err) {
573
+ if (err.code === 'ENOENT') {
574
+ sendError(res, 404, 'Stats file not found: ~/.claude/stats-cache.json');
575
+ } else if (err instanceof SyntaxError) {
576
+ sendError(res, 500, `Failed to parse stats file: ${err.message}`);
577
+ } else {
578
+ sendError(res, 500, `Failed to read stats file: ${err.message}`);
579
+ }
580
+ }
581
+ }
582
+ }
583
+
584
+ module.exports = { HttpServer };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * HTTP utility functions for the Vibe Monitor HTTP server
3
+ */
4
+
5
+ /**
6
+ * Set CORS headers on response
7
+ * Only allow localhost origins for security (prevents malicious web pages from accessing the API)
8
+ * @param {http.ServerResponse} res
9
+ * @param {http.IncomingMessage} req
10
+ */
11
+ function setCorsHeaders(res, req) {
12
+ const origin = req?.headers?.origin || '';
13
+ // Allow localhost origins only (IPv4, IPv6, with various port numbers)
14
+ const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin);
15
+
16
+ if (isLocalhost) {
17
+ res.setHeader('Access-Control-Allow-Origin', origin);
18
+ }
19
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
20
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
21
+ }
22
+
23
+ /**
24
+ * Send JSON response
25
+ * @param {http.ServerResponse} res
26
+ * @param {number} statusCode
27
+ * @param {object} data
28
+ */
29
+ function sendJson(res, statusCode, data) {
30
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
31
+ res.end(JSON.stringify(data));
32
+ }
33
+
34
+ /**
35
+ * Send error response
36
+ * @param {http.ServerResponse} res
37
+ * @param {number} statusCode
38
+ * @param {string} message
39
+ */
40
+ function sendError(res, statusCode, message) {
41
+ sendJson(res, statusCode, { error: message });
42
+ }
43
+
44
+ // Request timeout in milliseconds (prevents Slowloris attacks)
45
+ const REQUEST_TIMEOUT = 30000;
46
+
47
+ /**
48
+ * Parse JSON body from request with size limit and timeout
49
+ * @param {http.IncomingMessage} req
50
+ * @param {number} maxSize - Maximum payload size in bytes
51
+ * @param {number} timeout - Request timeout in milliseconds
52
+ * @returns {Promise<{data: object|null, error: string|null, statusCode: number|null}>}
53
+ */
54
+ function parseJsonBody(req, maxSize, timeout = REQUEST_TIMEOUT) {
55
+ return new Promise((resolve) => {
56
+ const chunks = [];
57
+ let bodySize = 0;
58
+ let aborted = false;
59
+
60
+ // Timeout handler
61
+ const timer = setTimeout(() => {
62
+ if (!aborted) {
63
+ aborted = true;
64
+ req.destroy();
65
+ resolve({ data: null, error: 'Request timeout', statusCode: 408 });
66
+ }
67
+ }, timeout);
68
+
69
+ req.on('data', (chunk) => {
70
+ if (aborted) return;
71
+ bodySize += chunk.length;
72
+ if (bodySize > maxSize) {
73
+ aborted = true;
74
+ clearTimeout(timer);
75
+ req.destroy();
76
+ resolve({ data: null, error: 'Payload too large', statusCode: 413 });
77
+ return;
78
+ }
79
+ chunks.push(chunk);
80
+ });
81
+
82
+ req.on('end', () => {
83
+ clearTimeout(timer);
84
+ if (aborted) return;
85
+ try {
86
+ const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : '{}';
87
+ const data = JSON.parse(body);
88
+ resolve({ data, error: null, statusCode: null });
89
+ } catch (e) {
90
+ console.error('JSON parse error:', e.message);
91
+ resolve({ data: null, error: 'Invalid JSON', statusCode: 400 });
92
+ }
93
+ });
94
+
95
+ req.on('error', (err) => {
96
+ clearTimeout(timer);
97
+ if (!aborted) {
98
+ console.error('HTTP request error:', err.message);
99
+ resolve({ data: null, error: 'Request error', statusCode: 500 });
100
+ }
101
+ });
102
+ });
103
+ }
104
+
105
+ module.exports = {
106
+ setCorsHeaders,
107
+ sendJson,
108
+ sendError,
109
+ parseJsonBody
110
+ };