@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/CHANGELOG.md +44 -0
- package/CLAUDE.md +18 -0
- package/package.json +2 -2
- package/public/index.html +830 -32
- package/server.js +453 -6
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
|
|
434
|
-
|
|
435
|
-
|
|
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
|