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.
- package/README.md +40 -0
- package/assets/characters/apto.png +0 -0
- package/assets/characters/claw.png +0 -0
- package/assets/characters/clawd.png +0 -0
- package/assets/characters/kiro.png +0 -0
- package/assets/generators/generate-icons.js +86 -0
- package/assets/generators/icon-128.png +0 -0
- package/assets/generators/icon-16.png +0 -0
- package/assets/generators/icon-256.png +0 -0
- package/assets/generators/icon-32.png +0 -0
- package/assets/generators/icon-64.png +0 -0
- package/assets/generators/icon-generator.html +221 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/bin/cli.js +16 -0
- package/index.html +26 -0
- package/main.js +358 -0
- package/modules/http-server.cjs +584 -0
- package/modules/http-utils.cjs +110 -0
- package/modules/multi-window-manager.cjs +927 -0
- package/modules/state-manager.cjs +168 -0
- package/modules/tray-manager.cjs +660 -0
- package/modules/validators.cjs +180 -0
- package/modules/ws-client.cjs +313 -0
- package/package.json +112 -0
- package/preload.js +22 -0
- package/renderer.js +84 -0
- package/shared/config.cjs +64 -0
- package/shared/constants.cjs +8 -0
- package/shared/data/constants.json +86 -0
- package/stats.html +521 -0
- package/styles.css +90 -0
|
@@ -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
|
+
};
|