ai-cli-online 2.9.9 → 3.0.1

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.
@@ -5,19 +5,19 @@ import { createServer as createHttpsServer } from 'https';
5
5
  import { WebSocketServer } from 'ws';
6
6
  import helmet from 'helmet';
7
7
  import rateLimit from 'express-rate-limit';
8
- import multer from 'multer';
9
8
  import { config } from 'dotenv';
10
- import { existsSync, readFileSync, createReadStream } from 'fs';
11
- import { spawn } from 'child_process';
12
- import { copyFile, unlink, stat, mkdir, readFile, writeFile, rm } from 'fs/promises';
13
- import { join, dirname, basename, extname } from 'path';
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { join, dirname } from 'path';
14
11
  import { fileURLToPath } from 'url';
15
- import { createHash } from 'crypto';
16
- import { setupWebSocket, getActiveSessionNames, clearWsIntervals } from './websocket.js';
17
- import { isTmuxAvailable, listSessions, buildSessionName, killSession, isValidSessionId, cleanupStaleSessions, getCwd, getPaneCommand } from './tmux.js';
18
- import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
19
- import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, getAnnotation, saveAnnotation, cleanupOldAnnotations, closeDb } from './db.js';
12
+ import { setupWebSocket, clearWsIntervals } from './websocket.js';
13
+ import { isTmuxAvailable, cleanupStaleSessions } from './tmux.js';
14
+ import { cleanupOldDrafts, cleanupOldAnnotations, closeDb } from './db.js';
20
15
  import { safeTokenCompare } from './auth.js';
16
+ // Route modules
17
+ import sessionsRouter from './routes/sessions.js';
18
+ import filesRouter from './routes/files.js';
19
+ import editorRouter from './routes/editor.js';
20
+ import settingsRouter from './routes/settings.js';
21
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
22
  config();
23
23
  const PORT = process.env.PORT || 3001;
@@ -25,20 +25,18 @@ const HOST = process.env.HOST || '0.0.0.0';
25
25
  const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
26
26
  const DEFAULT_WORKING_DIR = process.env.DEFAULT_WORKING_DIR || process.env.HOME || '/home/ubuntu';
27
27
  const HTTPS_ENABLED = process.env.HTTPS_ENABLED !== 'false';
28
- const CORS_ORIGIN = process.env.CORS_ORIGIN || ''; // empty = no CORS headers (same-origin only)
29
- const TRUST_PROXY = process.env.TRUST_PROXY || ''; // set to '1' when behind a reverse proxy
28
+ const CORS_ORIGIN = process.env.CORS_ORIGIN || '';
29
+ const TRUST_PROXY = process.env.TRUST_PROXY || '';
30
30
  const MAX_CONNECTIONS = parseInt(process.env.MAX_CONNECTIONS || '10', 10);
31
31
  const SESSION_TTL_HOURS = parseInt(process.env.SESSION_TTL_HOURS || '24', 10);
32
32
  const RATE_LIMIT_READ = parseInt(process.env.RATE_LIMIT_READ || '180', 10);
33
33
  const RATE_LIMIT_WRITE = parseInt(process.env.RATE_LIMIT_WRITE || '60', 10);
34
34
  const CERT_PATH = join(__dirname, '../certs/server.crt');
35
35
  const KEY_PATH = join(__dirname, '../certs/server.key');
36
- // Catch unhandled promise rejections to prevent silent crashes
37
36
  process.on('unhandledRejection', (reason) => {
38
37
  console.error('[FATAL] Unhandled promise rejection:', reason);
39
38
  });
40
39
  async function main() {
41
- // Check tmux availability
42
40
  if (!isTmuxAvailable()) {
43
41
  console.error('ERROR: tmux is not available. Please install it first.');
44
42
  console.error('Run: sudo apt install tmux');
@@ -46,13 +44,11 @@ async function main() {
46
44
  }
47
45
  console.log('tmux is available');
48
46
  const app = express();
49
- // Only trust proxy headers when explicitly configured (prevents IP spoofing without proxy)
50
47
  if (TRUST_PROXY) {
51
48
  app.set('trust proxy', parseInt(TRUST_PROXY, 10) || TRUST_PROXY);
52
49
  }
53
- // Compress HTTP responses (WebSocket has its own perMessageDeflate)
50
+ // --- Middleware ---
54
51
  app.use(compression());
55
- // Security headers
56
52
  app.use(helmet({
57
53
  contentSecurityPolicy: {
58
54
  directives: {
@@ -69,7 +65,6 @@ async function main() {
69
65
  },
70
66
  frameguard: { action: 'deny' },
71
67
  }));
72
- // Rate limiting — higher limit for read-only GET, lower for mutations
73
68
  app.use('/api/', rateLimit({
74
69
  windowMs: 60 * 1000,
75
70
  max: (req) => req.method === 'GET' ? RATE_LIMIT_READ : RATE_LIMIT_WRITE,
@@ -77,9 +72,7 @@ async function main() {
77
72
  legacyHeaders: false,
78
73
  message: { error: 'Too many requests' },
79
74
  }));
80
- // JSON body parser for draft API
81
75
  app.use(express.json({ limit: '256kb' }));
82
- // CORS (only add headers when CORS_ORIGIN is explicitly configured)
83
76
  if (CORS_ORIGIN) {
84
77
  app.use((_req, res, next) => {
85
78
  res.header('Access-Control-Allow-Origin', CORS_ORIGIN);
@@ -91,528 +84,17 @@ async function main() {
91
84
  next();
92
85
  });
93
86
  }
94
- // Auth check helper — reads Authorization header only (no query param to avoid token in logs)
95
- function extractToken(req) {
96
- const authHeader = req.headers.authorization;
97
- if (authHeader && authHeader.startsWith('Bearer ')) {
98
- return authHeader.slice(7);
99
- }
100
- return undefined;
101
- }
102
- function checkAuth(req, res) {
103
- if (!AUTH_TOKEN)
104
- return true;
105
- const token = extractToken(req);
106
- if (!token || !safeTokenCompare(token, AUTH_TOKEN)) {
107
- res.status(401).json({ error: 'Unauthorized' });
108
- return false;
109
- }
110
- return true;
111
- }
112
- // Health check
87
+ // --- Routes ---
113
88
  app.get('/api/health', (_req, res) => {
114
89
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
115
90
  });
116
- // List sessions for a token
117
- app.get('/api/sessions', async (req, res) => {
118
- if (!checkAuth(req, res))
119
- return;
120
- const token = extractToken(req) || 'default';
121
- const sessions = await listSessions(token);
122
- const activeNames = getActiveSessionNames();
123
- const result = sessions.map((s) => ({
124
- sessionId: s.sessionId,
125
- sessionName: s.sessionName,
126
- createdAt: s.createdAt,
127
- active: activeNames.has(s.sessionName),
128
- }));
129
- res.json(result);
130
- });
131
- // Kill a specific session
132
- app.delete('/api/sessions/:sessionId', async (req, res) => {
133
- if (!checkAuth(req, res))
134
- return;
135
- if (!isValidSessionId(req.params.sessionId)) {
136
- res.status(400).json({ error: 'Invalid sessionId' });
137
- return;
138
- }
139
- const token = extractToken(req) || 'default';
140
- const sessionName = buildSessionName(token, req.params.sessionId);
141
- await killSession(sessionName);
142
- deleteDraft(sessionName);
143
- res.json({ ok: true });
144
- });
145
- // --- File transfer APIs ---
146
- const UPLOAD_TMP_DIR = '/tmp/ai-cli-online-uploads';
147
- await mkdir(UPLOAD_TMP_DIR, { recursive: true, mode: 0o700 });
148
- const upload = multer({
149
- dest: UPLOAD_TMP_DIR,
150
- limits: { fileSize: MAX_UPLOAD_SIZE, files: 10 },
151
- });
152
- /** Helper: resolve session from request params + auth */
153
- function resolveSession(req, res) {
154
- if (!checkAuth(req, res))
155
- return null;
156
- const sessionId = req.params.sessionId;
157
- if (!isValidSessionId(sessionId)) {
158
- res.status(400).json({ error: 'Invalid sessionId' });
159
- return null;
160
- }
161
- const token = extractToken(req) || 'default';
162
- return buildSessionName(token, sessionId);
163
- }
164
- // Get current working directory
165
- app.get('/api/sessions/:sessionId/cwd', async (req, res) => {
166
- const sessionName = resolveSession(req, res);
167
- if (!sessionName)
168
- return;
169
- try {
170
- const cwd = await getCwd(sessionName);
171
- res.json({ cwd });
172
- }
173
- catch (err) {
174
- console.error(`[api:cwd] ${sessionName}:`, err);
175
- res.status(404).json({ error: 'Session not found or not running' });
176
- }
177
- });
178
- // List files in directory
179
- app.get('/api/sessions/:sessionId/files', async (req, res) => {
180
- const sessionName = resolveSession(req, res);
181
- if (!sessionName)
182
- return;
183
- try {
184
- const cwd = await getCwd(sessionName);
185
- const subPath = req.query.path || '';
186
- let targetDir = null;
187
- if (subPath) {
188
- targetDir = await validatePath(subPath, cwd);
189
- // Fallback: allow absolute paths under HOME (e.g., ~/.claude/commands)
190
- if (!targetDir) {
191
- const home = process.env.HOME || '/root';
192
- targetDir = await validatePath(subPath, home);
193
- }
194
- }
195
- else {
196
- targetDir = cwd;
197
- }
198
- if (!targetDir) {
199
- res.status(400).json({ error: 'Invalid path' });
200
- return;
201
- }
202
- const { files, truncated } = await listFiles(targetDir);
203
- res.json({ cwd: targetDir, home: process.env.HOME || '/root', files, truncated });
204
- }
205
- catch (err) {
206
- console.error(`[api:files] ${sessionName}:`, err);
207
- res.status(404).json({ error: 'Session not found or directory not accessible' });
208
- }
209
- });
210
- // Upload files to CWD
211
- app.post('/api/sessions/:sessionId/upload', upload.array('files', 10), async (req, res) => {
212
- const sessionName = resolveSession(req, res);
213
- if (!sessionName)
214
- return;
215
- try {
216
- const cwd = await getCwd(sessionName);
217
- const uploadedFiles = req.files;
218
- if (!uploadedFiles || uploadedFiles.length === 0) {
219
- res.status(400).json({ error: 'No files provided' });
220
- return;
221
- }
222
- const results = [];
223
- for (const file of uploadedFiles) {
224
- // Sanitize filename: strip path components to prevent directory traversal
225
- const safeName = basename(file.originalname);
226
- if (!safeName || safeName === '.' || safeName === '..') {
227
- await unlink(file.path).catch(() => { });
228
- continue;
229
- }
230
- const destPath = join(cwd, safeName);
231
- // Use copyFile + unlink instead of rename to handle cross-device moves
232
- await copyFile(file.path, destPath);
233
- await unlink(file.path).catch(() => { });
234
- results.push({ name: safeName, size: file.size });
235
- }
236
- res.json({ uploaded: results });
237
- }
238
- catch (err) {
239
- console.error('[upload] Failed:', err);
240
- // Clean up multer temp files on error to prevent disk leak
241
- const files = req.files;
242
- if (files) {
243
- for (const f of files) {
244
- await unlink(f.path).catch(() => { });
245
- }
246
- }
247
- res.status(500).json({ error: 'Upload failed' });
248
- }
249
- });
250
- // Download a file
251
- app.get('/api/sessions/:sessionId/download', async (req, res) => {
252
- const sessionName = resolveSession(req, res);
253
- if (!sessionName)
254
- return;
255
- try {
256
- const cwd = await getCwd(sessionName);
257
- const filePath = req.query.path;
258
- if (!filePath) {
259
- res.status(400).json({ error: 'path query parameter required' });
260
- return;
261
- }
262
- const resolved = await validatePathNoSymlink(filePath, cwd);
263
- if (!resolved) {
264
- res.status(400).json({ error: 'Invalid path' });
265
- return;
266
- }
267
- const fileStat = await stat(resolved);
268
- if (!fileStat.isFile()) {
269
- res.status(400).json({ error: 'Not a file' });
270
- return;
271
- }
272
- if (fileStat.size > MAX_DOWNLOAD_SIZE) {
273
- res.status(413).json({ error: 'File too large (max 100MB)' });
274
- return;
275
- }
276
- res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
277
- res.setHeader('Content-Length', fileStat.size);
278
- // A3: Stream with byte counting to guard against TOCTOU size changes
279
- const stream = createReadStream(resolved);
280
- let bytesWritten = 0;
281
- stream.on('data', (chunk) => {
282
- const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
283
- bytesWritten += buf.length;
284
- if (bytesWritten > MAX_DOWNLOAD_SIZE) {
285
- stream.destroy();
286
- if (!res.writableEnded)
287
- res.end();
288
- return;
289
- }
290
- if (!res.writableEnded)
291
- res.write(chunk);
292
- });
293
- stream.on('end', () => { if (!res.writableEnded)
294
- res.end(); });
295
- stream.on('error', () => { if (!res.writableEnded)
296
- res.end(); });
297
- }
298
- catch (err) {
299
- console.error(`[api:download] ${sessionName}:`, err);
300
- res.status(404).json({ error: 'File not found' });
301
- }
302
- });
303
- // Download CWD as tar.gz
304
- app.get('/api/sessions/:sessionId/download-cwd', async (req, res) => {
305
- const sessionName = resolveSession(req, res);
306
- if (!sessionName)
307
- return;
308
- try {
309
- const cwd = await getCwd(sessionName);
310
- // A5: Validate CWD format before passing to tar
311
- if (!cwd.startsWith('/') || cwd.includes('\0')) {
312
- res.status(400).json({ error: 'Invalid working directory' });
313
- return;
314
- }
315
- const dirName = basename(cwd);
316
- res.setHeader('Content-Type', 'application/gzip');
317
- res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
318
- const tar = spawn('tar', ['czf', '-', '-C', cwd, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
319
- tar.stdout.pipe(res);
320
- tar.stderr.on('data', (data) => console.error(`[tar stderr] ${data}`));
321
- tar.on('error', (err) => {
322
- console.error('[api:download-cwd] tar error:', err);
323
- if (!res.headersSent)
324
- res.status(500).json({ error: 'Failed to create archive' });
325
- });
326
- tar.on('close', (code) => {
327
- if (code !== 0 && !res.headersSent) {
328
- res.status(500).json({ error: 'Archive creation failed' });
329
- }
330
- });
331
- }
332
- catch (err) {
333
- console.error(`[api:download-cwd] ${sessionName}:`, err);
334
- if (!res.headersSent)
335
- res.status(500).json({ error: 'Failed to download' });
336
- }
337
- });
338
- // --- Draft API ---
339
- // Get draft for a session
340
- app.get('/api/sessions/:sessionId/draft', (req, res) => {
341
- const sessionName = resolveSession(req, res);
342
- if (!sessionName)
343
- return;
344
- const content = getDraft(sessionName);
345
- res.json({ content });
346
- });
347
- // Save (upsert) draft for a session
348
- app.put('/api/sessions/:sessionId/draft', (req, res) => {
349
- const sessionName = resolveSession(req, res);
350
- if (!sessionName)
351
- return;
352
- const { content } = req.body;
353
- if (typeof content !== 'string') {
354
- res.status(400).json({ error: 'content must be a string' });
355
- return;
356
- }
357
- saveDraftDb(sessionName, content);
358
- res.json({ ok: true });
359
- });
360
- // --- Annotations API ---
361
- // Get annotation for a file
362
- app.get('/api/sessions/:sessionId/annotations', (req, res) => {
363
- const sessionName = resolveSession(req, res);
364
- if (!sessionName)
365
- return;
366
- const filePath = req.query.path;
367
- if (!filePath) {
368
- res.status(400).json({ error: 'path query parameter required' });
369
- return;
370
- }
371
- const result = getAnnotation(sessionName, filePath);
372
- res.json(result || { content: null, updatedAt: 0 });
373
- });
374
- // Save (upsert) annotation for a file
375
- app.put('/api/sessions/:sessionId/annotations', (req, res) => {
376
- const sessionName = resolveSession(req, res);
377
- if (!sessionName)
378
- return;
379
- const { path: filePath, content, updatedAt } = req.body;
380
- if (!filePath || typeof filePath !== 'string') {
381
- res.status(400).json({ error: 'path must be a string' });
382
- return;
383
- }
384
- if (typeof content !== 'string') {
385
- res.status(400).json({ error: 'content must be a string' });
386
- return;
387
- }
388
- saveAnnotation(sessionName, filePath, content, updatedAt || Date.now());
389
- res.json({ ok: true });
390
- });
391
- // --- Pane command API ---
392
- // Get current pane command (to detect if claude is running)
393
- app.get('/api/sessions/:sessionId/pane-command', async (req, res) => {
394
- const sessionName = resolveSession(req, res);
395
- if (!sessionName)
396
- return;
397
- try {
398
- const command = await getPaneCommand(sessionName);
399
- res.json({ command });
400
- }
401
- catch {
402
- res.json({ command: '' });
403
- }
404
- });
405
- // --- Touch (create empty file) API ---
406
- app.post('/api/sessions/:sessionId/touch', async (req, res) => {
407
- const sessionName = resolveSession(req, res);
408
- if (!sessionName)
409
- return;
410
- try {
411
- const { name } = req.body;
412
- if (!name || typeof name !== 'string' || name.includes('..')) {
413
- res.status(400).json({ error: 'Invalid filename' });
414
- return;
415
- }
416
- const cwd = await getCwd(sessionName);
417
- const resolved = await validateNewPath(join(cwd, name), cwd);
418
- if (!resolved) {
419
- res.status(400).json({ error: 'Invalid path' });
420
- return;
421
- }
422
- // Ensure parent directory exists (supports paths like "PLAN/INDEX.md")
423
- await mkdir(dirname(resolved), { recursive: true });
424
- await writeFile(resolved, '', { flag: 'wx' }); // create exclusively
425
- res.json({ ok: true, path: resolved });
426
- }
427
- catch (err) {
428
- if (err && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
429
- const cwd = await getCwd(sessionName).catch(() => '');
430
- res.json({ ok: true, existed: true, path: cwd ? join(cwd, String(req.body.name)) : '' });
431
- }
432
- else {
433
- console.error(`[api:touch] ${sessionName}:`, err);
434
- res.status(500).json({ error: 'Failed to create file' });
435
- }
436
- }
437
- });
438
- // --- Mkdir (create directory) API ---
439
- app.post('/api/sessions/:sessionId/mkdir', async (req, res) => {
440
- const sessionName = resolveSession(req, res);
441
- if (!sessionName)
442
- return;
443
- try {
444
- const { path: dirPath } = req.body;
445
- if (!dirPath || typeof dirPath !== 'string' || dirPath.includes('..')) {
446
- res.status(400).json({ error: 'Invalid path' });
447
- return;
448
- }
449
- const cwd = await getCwd(sessionName);
450
- const resolved = await validateNewPath(join(cwd, dirPath), cwd);
451
- if (!resolved) {
452
- res.status(400).json({ error: 'Invalid path' });
453
- return;
454
- }
455
- await mkdir(resolved, { recursive: true });
456
- res.json({ ok: true, path: resolved });
457
- }
458
- catch (err) {
459
- console.error(`[api:mkdir] ${sessionName}:`, err);
460
- res.status(500).json({ error: 'Failed to create directory' });
461
- }
462
- });
463
- // --- Delete file/directory API ---
464
- app.delete('/api/sessions/:sessionId/rm', async (req, res) => {
465
- const sessionName = resolveSession(req, res);
466
- if (!sessionName)
467
- return;
468
- try {
469
- const { path: rmPath } = req.body;
470
- if (!rmPath || typeof rmPath !== 'string' || rmPath.includes('..')) {
471
- res.status(400).json({ error: 'Invalid path' });
472
- return;
473
- }
474
- const cwd = await getCwd(sessionName);
475
- const resolved = await validatePath(rmPath, cwd);
476
- if (!resolved) {
477
- res.status(400).json({ error: 'Invalid path' });
478
- return;
479
- }
480
- const fileStat = await stat(resolved);
481
- if (fileStat.isDirectory()) {
482
- await rm(resolved, { recursive: true });
483
- }
484
- else {
485
- await unlink(resolved);
486
- }
487
- res.json({ ok: true });
488
- }
489
- catch (err) {
490
- console.error(`[api:rm] ${sessionName}:`, err);
491
- res.status(500).json({ error: 'Failed to delete' });
492
- }
493
- });
494
- // --- Settings API ---
495
- /** Hash token for settings storage (same prefix as tmux session names) */
496
- function tokenHash(token) {
497
- return createHash('sha256').update(token).digest('hex').slice(0, 8);
498
- }
499
- app.get('/api/settings/font-size', (req, res) => {
500
- if (!checkAuth(req, res))
501
- return;
502
- const token = extractToken(req) || 'default';
503
- const value = getSetting(tokenHash(token), 'font-size');
504
- const fontSize = value !== null ? parseInt(value, 10) : 14;
505
- res.json({ fontSize: isNaN(fontSize) ? 14 : fontSize });
506
- });
507
- app.put('/api/settings/font-size', (req, res) => {
508
- if (!checkAuth(req, res))
509
- return;
510
- const token = extractToken(req) || 'default';
511
- const { fontSize } = req.body;
512
- if (typeof fontSize !== 'number' || fontSize < 10 || fontSize > 24) {
513
- res.status(400).json({ error: 'fontSize must be a number between 10 and 24' });
514
- return;
515
- }
516
- saveSetting(tokenHash(token), 'font-size', String(fontSize));
517
- res.json({ ok: true });
518
- });
519
- // --- Tabs layout persistence API ---
520
- app.get('/api/settings/tabs-layout', (req, res) => {
521
- if (!checkAuth(req, res))
522
- return;
523
- const token = extractToken(req) || 'default';
524
- const value = getSetting(tokenHash(token), 'tabs-layout');
525
- let layout = null;
526
- if (value) {
527
- try {
528
- layout = JSON.parse(value);
529
- }
530
- catch { /* corrupt data */ }
531
- }
532
- res.json({ layout });
533
- });
534
- app.put('/api/settings/tabs-layout', (req, res) => {
535
- const { layout, token: bodyToken } = req.body;
536
- // Support both Authorization header and body token (for sendBeacon)
537
- let token;
538
- if (AUTH_TOKEN) {
539
- token = extractToken(req);
540
- if (!token && bodyToken) {
541
- // sendBeacon path: validate token from body
542
- if (!safeTokenCompare(bodyToken, AUTH_TOKEN)) {
543
- res.status(401).json({ error: 'Unauthorized' });
544
- return;
545
- }
546
- token = bodyToken;
547
- }
548
- if (!token) {
549
- res.status(401).json({ error: 'Unauthorized' });
550
- return;
551
- }
552
- }
553
- else {
554
- token = extractToken(req) || bodyToken || 'default';
555
- }
556
- if (!layout || typeof layout !== 'object') {
557
- res.status(400).json({ error: 'layout must be an object' });
558
- return;
559
- }
560
- saveSetting(tokenHash(token), 'tabs-layout', JSON.stringify(layout));
561
- res.json({ ok: true });
562
- });
563
- // --- Document browser: file content API ---
564
- const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
565
- const PDF_EXTENSIONS = new Set(['.pdf']);
566
- app.get('/api/sessions/:sessionId/file-content', async (req, res) => {
567
- const sessionName = resolveSession(req, res);
568
- if (!sessionName)
569
- return;
570
- const filePath = req.query.path;
571
- if (!filePath) {
572
- res.status(400).json({ error: 'path query parameter required' });
573
- return;
574
- }
575
- try {
576
- const cwd = await getCwd(sessionName);
577
- const resolved = await validatePathNoSymlink(filePath, cwd);
578
- if (!resolved) {
579
- res.status(400).json({ error: 'Invalid path' });
580
- return;
581
- }
582
- const fileStat = await stat(resolved);
583
- if (!fileStat.isFile()) {
584
- res.status(400).json({ error: 'Not a file' });
585
- return;
586
- }
587
- if (fileStat.size > MAX_DOC_SIZE) {
588
- res.status(413).json({ error: 'File too large (max 10MB)' });
589
- return;
590
- }
591
- // 304 check: if client sends `since` and file hasn't changed
592
- const since = parseFloat(req.query.since) || 0;
593
- if (since > 0 && fileStat.mtimeMs <= since) {
594
- res.status(304).end();
595
- return;
596
- }
597
- const ext = extname(resolved).toLowerCase();
598
- const isPdf = PDF_EXTENSIONS.has(ext);
599
- const content = await readFile(resolved, isPdf ? undefined : 'utf-8');
600
- res.json({
601
- content: isPdf ? content.toString('base64') : content,
602
- mtime: fileStat.mtimeMs,
603
- size: fileStat.size,
604
- encoding: isPdf ? 'base64' : 'utf-8',
605
- });
606
- }
607
- catch (err) {
608
- console.error(`[api:file-content] ${sessionName}:`, err);
609
- res.status(404).json({ error: 'File not found' });
610
- }
611
- });
612
- // Serve static files from web/dist in production
91
+ app.use(sessionsRouter);
92
+ app.use(filesRouter);
93
+ app.use(editorRouter);
94
+ app.use(settingsRouter);
95
+ // --- Static files ---
613
96
  const webDistPath = join(__dirname, '../../web/dist');
614
97
  if (existsSync(webDistPath)) {
615
- // Vite generates content-hashed filenames — safe to cache indefinitely
616
98
  app.use(express.static(webDistPath, {
617
99
  maxAge: '1y',
618
100
  immutable: true,
@@ -622,13 +104,12 @@ async function main() {
622
104
  if (req.path.startsWith('/api') || req.path.startsWith('/ws')) {
623
105
  return next();
624
106
  }
625
- // index.html must not be cached (references hashed assets)
626
107
  res.setHeader('Cache-Control', 'no-cache');
627
108
  res.sendFile(join(webDistPath, 'index.html'));
628
109
  });
629
110
  console.log('Serving static files from:', webDistPath);
630
111
  }
631
- // SSL setup
112
+ // --- Server startup ---
632
113
  const hasSSL = existsSync(CERT_PATH) && existsSync(KEY_PATH);
633
114
  const useHttps = HTTPS_ENABLED && hasSSL;
634
115
  let server;
@@ -642,16 +123,15 @@ async function main() {
642
123
  console.log('WARNING: HTTPS enabled but certificates not found, falling back to HTTP');
643
124
  }
644
125
  }
645
- // WebSocket server with compression and increased payload limit
646
126
  const wss = new WebSocketServer({
647
127
  server,
648
128
  path: '/ws',
649
- maxPayload: 1024 * 1024, // 1MB (supports large paste operations)
129
+ maxPayload: 1024 * 1024,
650
130
  perMessageDeflate: {
651
- zlibDeflateOptions: { level: 1 }, // Fastest compression to avoid CPU bottleneck
652
- threshold: 128, // Only compress messages > 128 bytes
131
+ zlibDeflateOptions: { level: 1 },
132
+ threshold: 128,
653
133
  concurrencyLimit: 10,
654
- clientNoContextTakeover: true, // Stateless compression — better proxy compatibility
134
+ clientNoContextTakeover: true,
655
135
  serverNoContextTakeover: true,
656
136
  },
657
137
  });
@@ -671,7 +151,7 @@ async function main() {
671
151
  console.log('='.repeat(50));
672
152
  console.log('');
673
153
  });
674
- // Run startup cleanup immediately (clear stale drafts from previous runs)
154
+ // --- Cleanup ---
675
155
  try {
676
156
  const purged = cleanupOldDrafts(7);
677
157
  if (purged > 0)
@@ -680,10 +160,9 @@ async function main() {
680
160
  catch (e) {
681
161
  console.error('[startup:drafts]', e);
682
162
  }
683
- // Periodic cleanup of stale tmux sessions
684
163
  let cleanupTimer = null;
685
164
  if (SESSION_TTL_HOURS > 0) {
686
- const CLEANUP_INTERVAL = 60 * 60 * 1000; // every hour
165
+ const CLEANUP_INTERVAL = 60 * 60 * 1000;
687
166
  cleanupTimer = setInterval(() => {
688
167
  cleanupStaleSessions(SESSION_TTL_HOURS).catch((e) => console.error('[cleanup]', e));
689
168
  try {
@@ -701,18 +180,15 @@ async function main() {
701
180
  }, CLEANUP_INTERVAL);
702
181
  console.log(`Session TTL: ${SESSION_TTL_HOURS}h (cleanup every hour)`);
703
182
  }
704
- // Graceful shutdown
183
+ // --- Graceful shutdown ---
705
184
  const shutdown = () => {
706
185
  console.log('\n[shutdown] Closing server...');
707
- // Clear all intervals to allow event loop to drain
708
186
  clearWsIntervals();
709
187
  if (cleanupTimer)
710
188
  clearInterval(cleanupTimer);
711
- // Close all WebSocket connections (triggers ws 'close' handlers which kill PTYs)
712
189
  wss.clients.forEach((client) => {
713
190
  client.close(1001, 'Server shutting down');
714
191
  });
715
- // Allow 500ms for WebSocket close handlers to fire and clean up PTYs
716
192
  setTimeout(() => {
717
193
  server.close(() => {
718
194
  try {
@@ -723,7 +199,6 @@ async function main() {
723
199
  process.exit(0);
724
200
  });
725
201
  }, 500);
726
- // Force exit after 5s if graceful close hangs
727
202
  setTimeout(() => {
728
203
  console.log('[shutdown] Forced exit');
729
204
  process.exit(1);