@mjasano/devtunnel 1.0.0 → 1.2.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/server.js CHANGED
@@ -4,6 +4,7 @@ const WebSocket = require('ws');
4
4
  const pty = require('@homebridge/node-pty-prebuilt-multiarch');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ const fs = require('fs').promises;
7
8
  const { spawn } = require('child_process');
8
9
  const crypto = require('crypto');
9
10
 
@@ -29,6 +30,98 @@ function generateSessionId() {
29
30
  return crypto.randomBytes(16).toString('hex');
30
31
  }
31
32
 
33
+ // Get list of tmux sessions
34
+ async function getTmuxSessions() {
35
+ return new Promise((resolve) => {
36
+ const tmux = spawn('tmux', ['list-sessions', '-F', '#{session_name}:#{session_windows}:#{session_attached}']);
37
+ let output = '';
38
+
39
+ tmux.stdout.on('data', (data) => {
40
+ output += data.toString();
41
+ });
42
+
43
+ tmux.on('close', (code) => {
44
+ if (code !== 0 || !output.trim()) {
45
+ resolve([]);
46
+ return;
47
+ }
48
+
49
+ const sessions = output.trim().split('\n').map(line => {
50
+ const [name, windows, attached] = line.split(':');
51
+ return {
52
+ name,
53
+ windows: parseInt(windows) || 1,
54
+ attached: attached === '1'
55
+ };
56
+ });
57
+
58
+ resolve(sessions);
59
+ });
60
+
61
+ tmux.on('error', () => {
62
+ resolve([]);
63
+ });
64
+ });
65
+ }
66
+
67
+ // Create session attached to tmux
68
+ function createTmuxSession(tmuxSessionName) {
69
+ const id = `tmux-${tmuxSessionName}-${Date.now()}`;
70
+
71
+ const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', tmuxSessionName], {
72
+ name: 'xterm-256color',
73
+ cols: 80,
74
+ rows: 24,
75
+ cwd: process.env.WORKSPACE || os.homedir(),
76
+ env: { ...process.env, TERM: 'xterm-256color' }
77
+ });
78
+
79
+ const session = {
80
+ id,
81
+ pty: ptyProcess,
82
+ outputBuffer: '',
83
+ clients: new Set(),
84
+ lastAccess: Date.now(),
85
+ createdAt: Date.now(),
86
+ alive: true,
87
+ type: 'tmux',
88
+ tmuxSession: tmuxSessionName
89
+ };
90
+
91
+ ptyProcess.onData((data) => {
92
+ session.outputBuffer += data;
93
+ if (session.outputBuffer.length > OUTPUT_BUFFER_SIZE) {
94
+ session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_SIZE);
95
+ }
96
+
97
+ session.clients.forEach(ws => {
98
+ if (ws.readyState === WebSocket.OPEN) {
99
+ ws.send(JSON.stringify({ type: 'output', data }));
100
+ }
101
+ });
102
+ });
103
+
104
+ ptyProcess.onExit(({ exitCode, signal }) => {
105
+ console.log(`Tmux session ${tmuxSessionName} detached with code ${exitCode}`);
106
+ session.alive = false;
107
+
108
+ session.clients.forEach(ws => {
109
+ if (ws.readyState === WebSocket.OPEN) {
110
+ ws.send(JSON.stringify({ type: 'exit', exitCode, signal }));
111
+ }
112
+ });
113
+
114
+ setTimeout(() => {
115
+ sessions.delete(id);
116
+ broadcastSessionList();
117
+ }, 5000);
118
+ });
119
+
120
+ sessions.set(id, session);
121
+ console.log(`Tmux session attached: ${tmuxSessionName}`);
122
+ return session;
123
+ }
124
+
32
125
  // Create or get existing session
33
126
  function getOrCreateSession(sessionId = null) {
34
127
  // If sessionId provided and exists, return it
@@ -119,7 +212,9 @@ function broadcastSessionList() {
119
212
  createdAt: s.createdAt,
120
213
  lastAccess: s.lastAccess,
121
214
  alive: s.alive,
122
- clients: s.clients.size
215
+ clients: s.clients.size,
216
+ type: s.type || 'shell',
217
+ tmuxSession: s.tmuxSession
123
218
  }));
124
219
 
125
220
  wss.clients.forEach(client => {
@@ -233,7 +328,7 @@ function stopTunnel(id) {
233
328
  }
234
329
 
235
330
  // WebSocket handling
236
- wss.on('connection', (ws) => {
331
+ wss.on('connection', async (ws) => {
237
332
  console.log('Client connected');
238
333
 
239
334
  let currentSession = null;
@@ -253,16 +348,38 @@ wss.on('connection', (ws) => {
253
348
  createdAt: s.createdAt,
254
349
  lastAccess: s.lastAccess,
255
350
  alive: s.alive,
256
- clients: s.clients.size
351
+ clients: s.clients.size,
352
+ type: s.type || 'shell',
353
+ tmuxSession: s.tmuxSession
257
354
  }));
258
355
  ws.send(JSON.stringify({ type: 'sessions', data: sessionList }));
259
356
 
357
+ // Send tmux sessions list
358
+ const tmuxSessions = await getTmuxSessions();
359
+ ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
360
+
260
361
  ws.on('message', (message) => {
261
362
  try {
262
363
  const msg = JSON.parse(message.toString());
263
364
 
264
365
  switch (msg.type) {
366
+ case 'detach':
367
+ // Detach from current session
368
+ if (currentSession) {
369
+ currentSession.clients.delete(ws);
370
+ currentSession.lastAccess = Date.now();
371
+ broadcastSessionList();
372
+ console.log(`Client detached from session ${currentSession.id}`);
373
+ currentSession = null;
374
+ }
375
+ break;
376
+
265
377
  case 'attach':
378
+ // Detach from current session first if any
379
+ if (currentSession) {
380
+ currentSession.clients.delete(ws);
381
+ currentSession.lastAccess = Date.now();
382
+ }
266
383
  // Attach to existing or new session
267
384
  try {
268
385
  currentSession = getOrCreateSession(msg.sessionId);
@@ -338,6 +455,43 @@ wss.on('connection', (ws) => {
338
455
  }
339
456
  break;
340
457
 
458
+ case 'attach-tmux':
459
+ if (msg.tmuxSession) {
460
+ try {
461
+ // Detach from current session if any
462
+ if (currentSession) {
463
+ currentSession.clients.delete(ws);
464
+ currentSession.lastAccess = Date.now();
465
+ broadcastSessionList();
466
+ }
467
+
468
+ currentSession = createTmuxSession(msg.tmuxSession);
469
+ currentSession.clients.add(ws);
470
+
471
+ ws.send(JSON.stringify({
472
+ type: 'attached',
473
+ sessionId: currentSession.id,
474
+ alive: currentSession.alive,
475
+ tmuxSession: msg.tmuxSession
476
+ }));
477
+
478
+ broadcastSessionList();
479
+ console.log(`Client attached to tmux session: ${msg.tmuxSession}`);
480
+ } catch (err) {
481
+ ws.send(JSON.stringify({
482
+ type: 'error',
483
+ message: err.message
484
+ }));
485
+ }
486
+ }
487
+ break;
488
+
489
+ case 'refresh-tmux':
490
+ getTmuxSessions().then(tmuxSessions => {
491
+ ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
492
+ });
493
+ break;
494
+
341
495
  default:
342
496
  console.log('Unknown message type:', msg.type);
343
497
  }
@@ -364,6 +518,257 @@ wss.on('connection', (ws) => {
364
518
  });
365
519
  });
366
520
 
521
+ // System Monitoring API
522
+ async function getMemoryInfo() {
523
+ const totalMem = os.totalmem();
524
+
525
+ // macOS: use vm_stat for accurate memory info
526
+ if (os.platform() === 'darwin') {
527
+ return new Promise((resolve) => {
528
+ const vmstat = spawn('vm_stat');
529
+ let output = '';
530
+
531
+ vmstat.stdout.on('data', (data) => {
532
+ output += data.toString();
533
+ });
534
+
535
+ vmstat.on('close', () => {
536
+ try {
537
+ const pageSize = 16384; // macOS default page size (16KB on Apple Silicon)
538
+ const lines = output.split('\n');
539
+
540
+ let free = 0, active = 0, inactive = 0, wired = 0, compressed = 0, purgeable = 0;
541
+
542
+ lines.forEach(line => {
543
+ const match = line.match(/^(.+):\s+(\d+)/);
544
+ if (match) {
545
+ const value = parseInt(match[2]) * pageSize;
546
+ const key = match[1].toLowerCase();
547
+ if (key.includes('free')) free = value;
548
+ else if (key.includes('active')) active = value;
549
+ else if (key.includes('inactive')) inactive = value;
550
+ else if (key.includes('wired')) wired = value;
551
+ else if (key.includes('compressed')) compressed = value;
552
+ else if (key.includes('purgeable')) purgeable = value;
553
+ }
554
+ });
555
+
556
+ // App Memory = Active + Wired + Compressed (what Activity Monitor shows)
557
+ const appMemory = active + wired + compressed;
558
+ const usage = Math.round((appMemory / totalMem) * 100);
559
+
560
+ resolve({
561
+ total: totalMem,
562
+ used: appMemory,
563
+ free: totalMem - appMemory,
564
+ usage
565
+ });
566
+ } catch {
567
+ // Fallback to os.freemem()
568
+ const freeMem = os.freemem();
569
+ resolve({
570
+ total: totalMem,
571
+ used: totalMem - freeMem,
572
+ free: freeMem,
573
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
574
+ });
575
+ }
576
+ });
577
+
578
+ vmstat.on('error', () => {
579
+ const freeMem = os.freemem();
580
+ resolve({
581
+ total: totalMem,
582
+ used: totalMem - freeMem,
583
+ free: freeMem,
584
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
585
+ });
586
+ });
587
+ });
588
+ }
589
+
590
+ // Other platforms: use os.freemem()
591
+ const freeMem = os.freemem();
592
+ return {
593
+ total: totalMem,
594
+ used: totalMem - freeMem,
595
+ free: freeMem,
596
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
597
+ };
598
+ }
599
+
600
+ async function getSystemInfo() {
601
+ const cpus = os.cpus();
602
+ const memory = await getMemoryInfo();
603
+
604
+ // Calculate CPU usage
605
+ let totalIdle = 0;
606
+ let totalTick = 0;
607
+ cpus.forEach(cpu => {
608
+ for (const type in cpu.times) {
609
+ totalTick += cpu.times[type];
610
+ }
611
+ totalIdle += cpu.times.idle;
612
+ });
613
+ const cpuUsage = Math.round((1 - totalIdle / totalTick) * 100);
614
+
615
+ return {
616
+ hostname: os.hostname(),
617
+ platform: os.platform(),
618
+ arch: os.arch(),
619
+ uptime: os.uptime(),
620
+ cpu: {
621
+ model: cpus[0]?.model || 'Unknown',
622
+ cores: cpus.length,
623
+ usage: cpuUsage
624
+ },
625
+ memory,
626
+ loadavg: os.loadavg()
627
+ };
628
+ }
629
+
630
+ app.get('/api/system', async (req, res) => {
631
+ res.json(await getSystemInfo());
632
+ });
633
+
634
+ // File System API
635
+ const WORKSPACE_ROOT = process.env.WORKSPACE || os.homedir();
636
+
637
+ // Get file/directory listing
638
+ app.get('/api/files', async (req, res) => {
639
+ try {
640
+ const requestedPath = req.query.path || '';
641
+ const fullPath = path.join(WORKSPACE_ROOT, requestedPath);
642
+
643
+ // Security: ensure path is within workspace
644
+ const normalizedPath = path.normalize(fullPath);
645
+ if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
646
+ return res.status(403).json({ error: 'Access denied' });
647
+ }
648
+
649
+ const stats = await fs.stat(normalizedPath);
650
+
651
+ if (stats.isDirectory()) {
652
+ const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
653
+ const items = await Promise.all(
654
+ entries
655
+ .filter(entry => !entry.name.startsWith('.')) // Hide hidden files
656
+ .map(async (entry) => {
657
+ const itemPath = path.join(normalizedPath, entry.name);
658
+ try {
659
+ const itemStats = await fs.stat(itemPath);
660
+ return {
661
+ name: entry.name,
662
+ path: path.join(requestedPath, entry.name),
663
+ isDirectory: entry.isDirectory(),
664
+ size: itemStats.size,
665
+ modified: itemStats.mtime
666
+ };
667
+ } catch {
668
+ return null;
669
+ }
670
+ })
671
+ );
672
+
673
+ // Sort: directories first, then files
674
+ const validItems = items.filter(Boolean).sort((a, b) => {
675
+ if (a.isDirectory !== b.isDirectory) {
676
+ return a.isDirectory ? -1 : 1;
677
+ }
678
+ return a.name.localeCompare(b.name);
679
+ });
680
+
681
+ res.json({
682
+ path: requestedPath,
683
+ items: validItems
684
+ });
685
+ } else {
686
+ res.json({
687
+ path: requestedPath,
688
+ isFile: true,
689
+ size: stats.size,
690
+ modified: stats.mtime
691
+ });
692
+ }
693
+ } catch (err) {
694
+ if (err.code === 'ENOENT') {
695
+ res.status(404).json({ error: 'Path not found' });
696
+ } else {
697
+ res.status(500).json({ error: err.message });
698
+ }
699
+ }
700
+ });
701
+
702
+ // Read file content
703
+ app.get('/api/files/read', async (req, res) => {
704
+ try {
705
+ const requestedPath = req.query.path;
706
+ if (!requestedPath) {
707
+ return res.status(400).json({ error: 'Path required' });
708
+ }
709
+
710
+ const fullPath = path.join(WORKSPACE_ROOT, requestedPath);
711
+ const normalizedPath = path.normalize(fullPath);
712
+
713
+ if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
714
+ return res.status(403).json({ error: 'Access denied' });
715
+ }
716
+
717
+ const stats = await fs.stat(normalizedPath);
718
+
719
+ // Limit file size (5MB)
720
+ if (stats.size > 5 * 1024 * 1024) {
721
+ return res.status(413).json({ error: 'File too large' });
722
+ }
723
+
724
+ const content = await fs.readFile(normalizedPath, 'utf-8');
725
+ res.json({
726
+ path: requestedPath,
727
+ content,
728
+ size: stats.size,
729
+ modified: stats.mtime
730
+ });
731
+ } catch (err) {
732
+ if (err.code === 'ENOENT') {
733
+ res.status(404).json({ error: 'File not found' });
734
+ } else {
735
+ res.status(500).json({ error: err.message });
736
+ }
737
+ }
738
+ });
739
+
740
+ // Write file content
741
+ app.post('/api/files/write', async (req, res) => {
742
+ try {
743
+ const { path: filePath, content } = req.body;
744
+
745
+ if (!filePath) {
746
+ return res.status(400).json({ error: 'Path required' });
747
+ }
748
+
749
+ const fullPath = path.join(WORKSPACE_ROOT, filePath);
750
+ const normalizedPath = path.normalize(fullPath);
751
+
752
+ if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
753
+ return res.status(403).json({ error: 'Access denied' });
754
+ }
755
+
756
+ // Ensure parent directory exists
757
+ await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
758
+
759
+ await fs.writeFile(normalizedPath, content, 'utf-8');
760
+ const stats = await fs.stat(normalizedPath);
761
+
762
+ res.json({
763
+ path: filePath,
764
+ size: stats.size,
765
+ modified: stats.mtime
766
+ });
767
+ } catch (err) {
768
+ res.status(500).json({ error: err.message });
769
+ }
770
+ });
771
+
367
772
  // REST API
368
773
  app.get('/api/sessions', (req, res) => {
369
774
  const sessionList = Array.from(sessions.values()).map(s => ({
@@ -430,9 +835,51 @@ app.get('/health', (req, res) => {
430
835
  });
431
836
  });
432
837
 
433
- const PORT = process.env.PORT || 3000;
434
- server.listen(PORT, '0.0.0.0', () => {
435
- console.log(`DevTunnel server running on http://localhost:${PORT}`);
838
+ const DEFAULT_PORT = process.env.PORT || 3000;
839
+
840
+ function findAvailablePort(startPort, maxAttempts = 10) {
841
+ return new Promise((resolve, reject) => {
842
+ let port = startPort;
843
+ let attempts = 0;
844
+
845
+ const tryPort = () => {
846
+ if (attempts >= maxAttempts) {
847
+ reject(new Error(`No available port found after ${maxAttempts} attempts`));
848
+ return;
849
+ }
850
+
851
+ const testServer = require('net').createServer();
852
+ testServer.once('error', (err) => {
853
+ if (err.code === 'EADDRINUSE') {
854
+ console.log(`Port ${port} is in use, trying ${port + 1}...`);
855
+ port++;
856
+ attempts++;
857
+ tryPort();
858
+ } else {
859
+ reject(err);
860
+ }
861
+ });
862
+
863
+ testServer.once('listening', () => {
864
+ testServer.close(() => {
865
+ resolve(port);
866
+ });
867
+ });
868
+
869
+ testServer.listen(port, '0.0.0.0');
870
+ };
871
+
872
+ tryPort();
873
+ });
874
+ }
875
+
876
+ findAvailablePort(DEFAULT_PORT).then((PORT) => {
877
+ server.listen(PORT, '0.0.0.0', () => {
878
+ console.log(`DevTunnel server running on http://localhost:${PORT}`);
879
+ });
880
+ }).catch((err) => {
881
+ console.error('Failed to start server:', err.message);
882
+ process.exit(1);
436
883
  });
437
884
 
438
885
  // Graceful shutdown