-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathserver.ts
More file actions
334 lines (284 loc) · 12.7 KB
/
server.ts
File metadata and controls
334 lines (284 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
/**
* Custom Next.js Server with WebSocket Support
* Integrates WebSocket server for real-time communication
* Issue #331: HTTPS support and auth cleanup on shutdown
*/
// IMPORTANT: Register uncaught exception handler FIRST, before any imports
// This ensures we catch WebSocket frame errors before other handlers
process.on('uncaughtException', (error: Error & { code?: string }) => {
// Check for WebSocket-related errors that are non-fatal
const isWebSocketError =
error.code === 'WS_ERR_INVALID_UTF8' ||
error.code === 'WS_ERR_INVALID_CLOSE_CODE' ||
error.code === 'WS_ERR_UNEXPECTED_RSV_1' ||
error.code === 'ECONNRESET' ||
error.code === 'EPIPE' ||
(error instanceof RangeError && error.message?.includes('Invalid WebSocket frame')) ||
error.message?.includes('write after end');
if (isWebSocketError) {
// Silently ignore these non-fatal WebSocket frame errors
// They commonly occur when mobile browsers send malformed close frames
return;
}
// For other uncaught exceptions, log and exit
console.error('Uncaught exception:', error);
process.exit(1);
});
import { createServer as createHttpServer } from 'http';
import { createServer as createHttpsServer } from 'https';
import { readFileSync, existsSync, accessSync, realpathSync, statSync } from 'fs';
import { constants as fsConstants } from 'fs';
import { parse } from 'url';
import next from 'next';
import { setupWebSocket, closeWebSocket } from './src/lib/ws-server';
import {
getRepositoryPaths,
scanMultipleRepositories,
} from './src/lib/git/worktrees';
import { getDbInstance } from './src/lib/db/db-instance';
import { stopAllPolling } from './src/lib/polling/response-poller';
import { stopAllAutoYesPolling } from './src/lib/polling/auto-yes-manager';
import { initScheduleManager, stopAllSchedules } from './src/lib/schedule-manager';
import { initTimerManager, stopAllTimers } from './src/lib/timer-manager';
import { initResourceCleanup, stopResourceCleanup } from './src/lib/resource-cleanup';
import { runMigrations } from './src/lib/db/db-migrations';
import { getEnvByKey } from './src/lib/env';
import { registerAndFilterRepositories, resolveRepositoryPath, getAllRepositories } from './src/lib/db/db-repository';
import { getWorktreeIdsByRepository, deleteWorktreesByIds } from './src/lib/db';
import { cleanupMultipleWorktrees, killWorktreeSession, syncWorktreesAndCleanup } from './src/lib/session-cleanup';
const dev = process.env.NODE_ENV !== 'production';
const hostname = getEnvByKey('CM_BIND') || '127.0.0.1';
const port = parseInt(getEnvByKey('CM_PORT') || '3000', 10);
// Issue #331: HTTPS configuration
const certPath = process.env.CM_HTTPS_CERT;
const keyPath = process.env.CM_HTTPS_KEY;
/** Maximum certificate file size: 1MB */
const MAX_CERT_FILE_SIZE = 1024 * 1024;
/**
* Validate certificate file path for security
* Issue #331: Certificate path validation
*/
function validateCertPath(filePath: string, label: string): string {
if (!existsSync(filePath)) {
console.error(`[Security] ${label} file not found: ${filePath}`);
// ExitCode.CONFIG_ERROR = 2 (direct number, not imported from CLI types)
process.exit(2);
}
try {
accessSync(filePath, fsConstants.R_OK);
} catch {
console.error(`[Security] ${label} file not readable: ${filePath}`);
process.exit(2);
}
const realPath = realpathSync(filePath);
const stats = statSync(realPath);
if (stats.size > MAX_CERT_FILE_SIZE) {
console.error(`[Security] ${label} file too large (${stats.size} bytes, max ${MAX_CERT_FILE_SIZE}): ${filePath}`);
process.exit(2);
}
return realPath;
}
// Create Next.js app
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
// Issue #331: Prevent Next.js (NextCustomServer) from registering its own upgrade
// event listener on the HTTP server.
//
// When next() is called without customServer:false, it creates a NextCustomServer
// instance. NextCustomServer.getRequestHandler() lazily calls setupWebSocketHandler()
// on the first HTTP request, which registers an upgrade listener that calls
// router-server's upgradeHandler → resolveRoutes({ res: socket }) → middleware match
// → serverResult.requestHandler(req, socket, parsedUrl). This passes the raw TCP
// socket as the HTTP response object, causing:
// TypeError: Cannot read properties of undefined (reading 'bind')
// in handleRequestImpl when it tries to call _res.setHeader.bind(_res).
//
// Making setupWebSocketHandler a no-op prevents this listener from being added.
// All WebSocket upgrades are handled by ws-server.ts (our own upgrade listener).
(app as unknown as { setupWebSocketHandler?: () => void }).setupWebSocketHandler = () => {};
app.prepare().then(() => {
// Request handler for both HTTP and HTTPS
const requestHandler = async (req: import('http').IncomingMessage, res: import('http').ServerResponse) => {
// Guard: res must be a proper HTTP ServerResponse (not a raw net.Socket).
// Defense-in-depth: normally not needed after the setupWebSocketHandler fix above,
// but kept for safety in case any other path passes a non-ServerResponse object.
if (typeof (res as unknown as { setHeader?: unknown })?.setHeader !== 'function') {
return;
}
// Issue #332: Inject X-Real-IP header for IP restriction
// [S3-005] This applies to HTTP requests only. WebSocket upgrade requests
// are skipped below and handled by ws-server.ts (which uses socket.remoteAddress directly).
const clientIp = req.socket.remoteAddress || '';
if (process.env.CM_TRUST_PROXY !== 'true') {
// CM_TRUST_PROXY=false: always overwrite to prevent forged headers
req.headers['x-real-ip'] = clientIp;
} else {
// CM_TRUST_PROXY=true: only set if X-Forwarded-For is absent
if (!req.headers['x-forwarded-for']) {
req.headers['x-real-ip'] = clientIp;
}
}
// Skip WebSocket upgrade requests - they are handled by the server 'upgrade' event.
if (req.headers['upgrade']) {
return;
}
const method = req.method ?? 'UNKNOWN';
const url = req.url ?? '/';
try {
const parsedUrl = parse(url, true);
await handle(req, res, parsedUrl);
} catch (err) {
console.error(`handleRequestImpl failed: ${method} ${url}`, err);
if (!res.headersSent) {
res.statusCode = 500;
res.end('Internal Server Error');
}
}
};
// Issue #331: Create HTTP or HTTPS server
let server: import('http').Server | import('https').Server;
let protocol = 'http';
if (certPath && keyPath) {
const validatedCertPath = validateCertPath(certPath, 'Certificate');
const validatedKeyPath = validateCertPath(keyPath, 'Key');
try {
const cert = readFileSync(validatedCertPath);
const key = readFileSync(validatedKeyPath);
server = createHttpsServer({ cert, key }, requestHandler);
protocol = 'https';
console.log('HTTPS server created with TLS certificates');
} catch (error) {
console.error('Failed to read TLS certificates:', error);
process.exit(2);
}
} else {
server = createHttpServer(requestHandler);
}
// Setup WebSocket server
setupWebSocket(server as import('http').Server);
// Scan and sync worktrees on startup
async function initializeWorktrees() {
try {
// Run database migrations first
console.log('Running database migrations...');
const db = getDbInstance();
runMigrations(db);
// Get repository paths from environment variables
const repositoryPaths = getRepositoryPaths();
// Issue #490: Also include DB-registered repositories (e.g. cloned repos)
const dbRepositories = getAllRepositories(db);
const dbEnabledPaths = dbRepositories
.filter(r => r.enabled)
.map(r => r.path);
// Merge env paths and DB-registered paths (deduplicate)
const allPaths = [...new Set([...repositoryPaths, ...dbEnabledPaths])];
if (allPaths.length === 0) {
console.warn('Warning: No repository paths configured');
console.warn('Set WORKTREE_REPOS (comma-separated) or MCBD_ROOT_DIR');
return;
}
console.log(`Configured repositories: ${allPaths.length}`);
allPaths.forEach((path, i) => {
console.log(` ${i + 1}. ${path}`);
});
// Issue #202: Register environment variable repositories and filter out excluded ones
// registerAndFilterRepositories() encapsulates the ordering constraint:
// registration MUST happen before filtering (see design policy Section 4)
const { filteredPaths, excludedPaths, excludedCount } =
registerAndFilterRepositories(db, allPaths);
if (excludedCount > 0) {
console.log(`Excluded repositories: ${excludedCount}, Active repositories: ${filteredPaths.length}`);
// SF-SEC-003: Log excluded repository paths for audit/troubleshooting
excludedPaths.forEach(p => {
console.log(` [excluded] ${p}`);
});
// Issue #202/#526: Remove worktrees of excluded repositories from DB
// SF-002: cleanup -> delete order for excluded repositories
// Sessions must be stopped before DB records are removed
for (const excludedPath of excludedPaths) {
const resolvedPath = resolveRepositoryPath(excludedPath);
const worktreeIds = getWorktreeIdsByRepository(db, resolvedPath);
if (worktreeIds.length > 0) {
// Issue #526: Clean up tmux sessions before deleting from DB
await cleanupMultipleWorktrees(worktreeIds, killWorktreeSession);
const result = deleteWorktreesByIds(db, worktreeIds);
console.log(` Removed ${result.deletedCount} worktree(s) from excluded repository: ${resolvedPath}`);
}
}
}
// Scan filtered repositories (excluded repos are skipped)
const worktrees = await scanMultipleRepositories(filteredPaths);
// Issue #526: Sync to database with cleanup (MF-001)
const { syncResult, cleanupWarnings } = await syncWorktreesAndCleanup(db, worktrees);
if (cleanupWarnings.length > 0) {
console.warn(`Sync cleanup warnings: ${cleanupWarnings.join(', ')}`);
}
if (syncResult.deletedIds.length > 0) {
console.log(`Cleaned up ${syncResult.deletedIds.length} deleted worktree(s)`);
}
console.log(`Total: ${worktrees.length} worktree(s) synced to database`);
} catch (error) {
console.error('Error initializing worktrees:', error);
}
}
// H3 fix: Pass hostname to listen() so CM_BIND is respected.
// Note: http.Server.listen(port, hostname, callback) does not pass err to callback;
// listen errors emit an 'error' event instead.
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${port} is already in use`);
} else if (err.code === 'EADDRNOTAVAIL') {
console.error(`Address ${hostname}:${port} is not available`);
} else {
console.error('Server error:', err);
}
process.exit(1);
});
server.listen(port, hostname, async () => {
console.log(`> Ready on ${protocol}://${hostname}:${port}`);
console.log(`> WebSocket server ready`);
// Initialize worktrees after server starts
await initializeWorktrees();
// [S3-010] Initialize schedule manager AFTER worktrees are ready
initScheduleManager();
// Issue #534: Initialize timer manager AFTER schedule manager
initTimerManager();
// Issue #404: Initialize resource cleanup AFTER schedule manager
initResourceCleanup();
});
// Graceful shutdown with timeout
let isShuttingDown = false;
function gracefulShutdown(signal: string) {
if (isShuttingDown) {
console.log('Shutdown already in progress, forcing exit...');
process.exit(1);
}
isShuttingDown = true;
console.log(`${signal} received: shutting down...`);
// Stop polling first
stopAllPolling();
// Issue #138: Stop all auto-yes pollers
stopAllAutoYesPolling();
// Issue #294: Stop all scheduled executions (SIGKILL fire-and-forget)
stopAllSchedules();
// Issue #534: Stop all timer-based delayed messages
stopAllTimers();
// Issue #404: Stop resource cleanup timer
stopResourceCleanup();
// Close WebSocket connections immediately (don't wait)
closeWebSocket();
// Force exit after 3 seconds if graceful shutdown fails
const forceExitTimeout = setTimeout(() => {
console.log('Graceful shutdown timeout, forcing exit...');
process.exit(1);
}, 3000);
// Try graceful HTTP server close
server.close(() => {
clearTimeout(forceExitTimeout);
console.log('Server closed gracefully');
process.exit(0);
});
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
});