@mjasano/devtunnel 1.0.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.
@@ -0,0 +1,814 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DevTunnel</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ background-color: #0d1117;
17
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ height: 100vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ color: #c9d1d9;
22
+ }
23
+
24
+ .header {
25
+ background-color: #161b22;
26
+ padding: 12px 20px;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ border-bottom: 1px solid #30363d;
31
+ }
32
+
33
+ .header h1 {
34
+ color: #fff;
35
+ font-size: 18px;
36
+ font-weight: 600;
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 10px;
40
+ }
41
+
42
+ .header h1 .logo {
43
+ width: 24px;
44
+ height: 24px;
45
+ background: linear-gradient(135deg, #58a6ff, #8b5cf6);
46
+ border-radius: 6px;
47
+ }
48
+
49
+ .header-right {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 16px;
53
+ }
54
+
55
+ .session-indicator {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 8px;
59
+ padding: 6px 12px;
60
+ background-color: #21262d;
61
+ border-radius: 6px;
62
+ font-size: 12px;
63
+ color: #8b949e;
64
+ }
65
+
66
+ .session-indicator .dot {
67
+ width: 8px;
68
+ height: 8px;
69
+ border-radius: 50%;
70
+ background-color: #3fb950;
71
+ }
72
+
73
+ .status {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 8px;
77
+ color: #8b949e;
78
+ font-size: 13px;
79
+ }
80
+
81
+ .status-dot {
82
+ width: 8px;
83
+ height: 8px;
84
+ border-radius: 50%;
85
+ background-color: #f85149;
86
+ }
87
+
88
+ .status-dot.connected {
89
+ background-color: #3fb950;
90
+ }
91
+
92
+ .main-container {
93
+ flex: 1;
94
+ display: flex;
95
+ overflow: hidden;
96
+ }
97
+
98
+ #terminal-container {
99
+ flex: 1;
100
+ padding: 10px;
101
+ background-color: #0d1117;
102
+ overflow: hidden;
103
+ }
104
+
105
+ #terminal {
106
+ height: 100%;
107
+ }
108
+
109
+ .sidebar {
110
+ width: 320px;
111
+ background-color: #161b22;
112
+ border-left: 1px solid #30363d;
113
+ display: flex;
114
+ flex-direction: column;
115
+ }
116
+
117
+ .sidebar-section {
118
+ border-bottom: 1px solid #30363d;
119
+ }
120
+
121
+ .sidebar-header {
122
+ padding: 12px 16px;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: space-between;
126
+ cursor: pointer;
127
+ }
128
+
129
+ .sidebar-header:hover {
130
+ background-color: #21262d;
131
+ }
132
+
133
+ .sidebar-header h2 {
134
+ font-size: 13px;
135
+ font-weight: 600;
136
+ color: #c9d1d9;
137
+ text-transform: uppercase;
138
+ letter-spacing: 0.5px;
139
+ }
140
+
141
+ .sidebar-header .count {
142
+ background-color: #30363d;
143
+ padding: 2px 8px;
144
+ border-radius: 10px;
145
+ font-size: 11px;
146
+ color: #8b949e;
147
+ }
148
+
149
+ .sidebar-content {
150
+ padding: 8px;
151
+ max-height: 200px;
152
+ overflow-y: auto;
153
+ }
154
+
155
+ .tunnel-form {
156
+ padding: 12px;
157
+ display: flex;
158
+ gap: 8px;
159
+ }
160
+
161
+ .tunnel-form input {
162
+ flex: 1;
163
+ padding: 8px 12px;
164
+ background-color: #0d1117;
165
+ border: 1px solid #30363d;
166
+ border-radius: 6px;
167
+ color: #c9d1d9;
168
+ font-size: 13px;
169
+ }
170
+
171
+ .tunnel-form input:focus {
172
+ outline: none;
173
+ border-color: #58a6ff;
174
+ }
175
+
176
+ .tunnel-form input::placeholder {
177
+ color: #6e7681;
178
+ }
179
+
180
+ .btn {
181
+ padding: 8px 16px;
182
+ border-radius: 6px;
183
+ border: none;
184
+ font-size: 13px;
185
+ font-weight: 500;
186
+ cursor: pointer;
187
+ transition: all 0.15s ease;
188
+ }
189
+
190
+ .btn-primary {
191
+ background-color: #238636;
192
+ color: #fff;
193
+ }
194
+
195
+ .btn-primary:hover {
196
+ background-color: #2ea043;
197
+ }
198
+
199
+ .btn-primary:disabled {
200
+ background-color: #21262d;
201
+ color: #484f58;
202
+ cursor: not-allowed;
203
+ }
204
+
205
+ .btn-sm {
206
+ padding: 4px 8px;
207
+ font-size: 12px;
208
+ }
209
+
210
+ .btn-danger {
211
+ background-color: transparent;
212
+ color: #f85149;
213
+ }
214
+
215
+ .btn-danger:hover {
216
+ background-color: rgba(248, 81, 73, 0.1);
217
+ }
218
+
219
+ .btn-ghost {
220
+ background-color: transparent;
221
+ color: #8b949e;
222
+ }
223
+
224
+ .btn-ghost:hover {
225
+ background-color: #21262d;
226
+ color: #c9d1d9;
227
+ }
228
+
229
+ .item-card {
230
+ background-color: #0d1117;
231
+ border: 1px solid #30363d;
232
+ border-radius: 8px;
233
+ padding: 10px 12px;
234
+ margin-bottom: 8px;
235
+ }
236
+
237
+ .item-card.active {
238
+ border-color: #58a6ff;
239
+ }
240
+
241
+ .item-card-header {
242
+ display: flex;
243
+ align-items: center;
244
+ justify-content: space-between;
245
+ margin-bottom: 6px;
246
+ }
247
+
248
+ .item-title {
249
+ font-weight: 600;
250
+ font-size: 13px;
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 8px;
254
+ }
255
+
256
+ .item-status {
257
+ font-size: 10px;
258
+ padding: 2px 6px;
259
+ border-radius: 10px;
260
+ font-weight: 500;
261
+ }
262
+
263
+ .item-status.active {
264
+ background-color: rgba(46, 160, 67, 0.2);
265
+ color: #3fb950;
266
+ }
267
+
268
+ .item-status.connecting {
269
+ background-color: rgba(187, 128, 9, 0.2);
270
+ color: #d29922;
271
+ }
272
+
273
+ .item-status.error, .item-status.stopped {
274
+ background-color: rgba(110, 118, 129, 0.2);
275
+ color: #8b949e;
276
+ }
277
+
278
+ .item-meta {
279
+ font-size: 11px;
280
+ color: #6e7681;
281
+ }
282
+
283
+ .item-url {
284
+ display: flex;
285
+ align-items: center;
286
+ gap: 6px;
287
+ margin-top: 8px;
288
+ }
289
+
290
+ .item-url input {
291
+ flex: 1;
292
+ padding: 5px 8px;
293
+ background-color: #161b22;
294
+ border: 1px solid #30363d;
295
+ border-radius: 4px;
296
+ color: #58a6ff;
297
+ font-size: 11px;
298
+ font-family: monospace;
299
+ }
300
+
301
+ .item-url input:focus {
302
+ outline: none;
303
+ }
304
+
305
+ .btn-copy {
306
+ padding: 5px 8px;
307
+ background-color: #21262d;
308
+ color: #c9d1d9;
309
+ border: 1px solid #30363d;
310
+ border-radius: 4px;
311
+ font-size: 11px;
312
+ cursor: pointer;
313
+ }
314
+
315
+ .btn-copy:hover {
316
+ background-color: #30363d;
317
+ }
318
+
319
+ .btn-copy.copied {
320
+ background-color: #238636;
321
+ border-color: #238636;
322
+ }
323
+
324
+ .empty-state {
325
+ padding: 24px 16px;
326
+ text-align: center;
327
+ color: #6e7681;
328
+ font-size: 12px;
329
+ }
330
+
331
+ .reconnect-overlay {
332
+ position: fixed;
333
+ top: 0;
334
+ left: 0;
335
+ right: 0;
336
+ bottom: 0;
337
+ background-color: rgba(0, 0, 0, 0.8);
338
+ display: none;
339
+ justify-content: center;
340
+ align-items: center;
341
+ z-index: 1000;
342
+ }
343
+
344
+ .reconnect-overlay.show {
345
+ display: flex;
346
+ }
347
+
348
+ .reconnect-box {
349
+ background-color: #161b22;
350
+ padding: 30px 40px;
351
+ border-radius: 12px;
352
+ text-align: center;
353
+ border: 1px solid #30363d;
354
+ }
355
+
356
+ .reconnect-box h2 {
357
+ color: #fff;
358
+ margin-bottom: 12px;
359
+ font-size: 18px;
360
+ }
361
+
362
+ .reconnect-box p {
363
+ color: #8b949e;
364
+ margin-bottom: 20px;
365
+ font-size: 14px;
366
+ }
367
+
368
+ .reconnect-btn {
369
+ background-color: #238636;
370
+ color: #fff;
371
+ border: none;
372
+ padding: 10px 24px;
373
+ border-radius: 6px;
374
+ cursor: pointer;
375
+ font-size: 14px;
376
+ font-weight: 500;
377
+ }
378
+
379
+ .reconnect-btn:hover {
380
+ background-color: #2ea043;
381
+ }
382
+
383
+ .toast {
384
+ position: fixed;
385
+ bottom: 20px;
386
+ right: 340px;
387
+ background-color: #161b22;
388
+ border: 1px solid #30363d;
389
+ padding: 12px 16px;
390
+ border-radius: 8px;
391
+ display: none;
392
+ align-items: center;
393
+ gap: 10px;
394
+ z-index: 1001;
395
+ animation: slideIn 0.3s ease;
396
+ }
397
+
398
+ .toast.show {
399
+ display: flex;
400
+ }
401
+
402
+ .toast.success {
403
+ border-color: #238636;
404
+ }
405
+
406
+ .toast.error {
407
+ border-color: #f85149;
408
+ }
409
+
410
+ @keyframes slideIn {
411
+ from {
412
+ transform: translateY(20px);
413
+ opacity: 0;
414
+ }
415
+ to {
416
+ transform: translateY(0);
417
+ opacity: 1;
418
+ }
419
+ }
420
+
421
+ @media (max-width: 768px) {
422
+ .main-container {
423
+ flex-direction: column;
424
+ }
425
+
426
+ .sidebar {
427
+ width: 100%;
428
+ max-height: 250px;
429
+ border-left: none;
430
+ border-top: 1px solid #30363d;
431
+ }
432
+
433
+ .toast {
434
+ right: 20px;
435
+ }
436
+ }
437
+ </style>
438
+ </head>
439
+ <body>
440
+ <div class="header">
441
+ <h1>
442
+ <div class="logo"></div>
443
+ DevTunnel
444
+ </h1>
445
+ <div class="header-right">
446
+ <div class="session-indicator" id="session-indicator" style="display: none;">
447
+ <span class="dot"></span>
448
+ <span>Session: <span id="session-id-display">-</span></span>
449
+ </div>
450
+ <div class="status">
451
+ <div class="status-dot" id="status-dot"></div>
452
+ <span id="status-text">Connecting...</span>
453
+ </div>
454
+ </div>
455
+ </div>
456
+
457
+ <div class="main-container">
458
+ <div id="terminal-container">
459
+ <div id="terminal"></div>
460
+ </div>
461
+
462
+ <div class="sidebar">
463
+ <!-- Sessions Section -->
464
+ <div class="sidebar-section">
465
+ <div class="sidebar-header" onclick="toggleSection('sessions')">
466
+ <h2>Sessions</h2>
467
+ <span class="count" id="session-count">0</span>
468
+ </div>
469
+ <div class="sidebar-content" id="sessions-content">
470
+ <button class="btn btn-primary btn-sm" style="width: 100%; margin-bottom: 8px;" onclick="createNewSession()">+ New Session</button>
471
+ <div id="session-list">
472
+ <div class="empty-state">No sessions</div>
473
+ </div>
474
+ </div>
475
+ </div>
476
+
477
+ <!-- Tunnels Section -->
478
+ <div class="sidebar-section">
479
+ <div class="sidebar-header" onclick="toggleSection('tunnels')">
480
+ <h2>Tunnels</h2>
481
+ <span class="count" id="tunnel-count">0</span>
482
+ </div>
483
+ <div class="tunnel-form">
484
+ <input type="number" id="port-input" placeholder="Port (e.g., 3001)" min="1" max="65535">
485
+ <button class="btn btn-primary" id="create-tunnel-btn">Expose</button>
486
+ </div>
487
+ <div class="sidebar-content" id="tunnels-content">
488
+ <div id="tunnel-list">
489
+ <div class="empty-state">No active tunnels</div>
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </div>
494
+ </div>
495
+
496
+ <div class="reconnect-overlay" id="reconnect-overlay">
497
+ <div class="reconnect-box">
498
+ <h2>Connection Lost</h2>
499
+ <p>The connection was closed.</p>
500
+ <button class="reconnect-btn" onclick="location.reload()">Reconnect</button>
501
+ </div>
502
+ </div>
503
+
504
+ <div class="toast" id="toast">
505
+ <span id="toast-message"></span>
506
+ </div>
507
+
508
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
509
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
510
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
511
+ <script>
512
+ const terminalContainer = document.getElementById('terminal');
513
+ const statusDot = document.getElementById('status-dot');
514
+ const statusText = document.getElementById('status-text');
515
+ const reconnectOverlay = document.getElementById('reconnect-overlay');
516
+ const portInput = document.getElementById('port-input');
517
+ const createTunnelBtn = document.getElementById('create-tunnel-btn');
518
+ const sessionIndicator = document.getElementById('session-indicator');
519
+ const sessionIdDisplay = document.getElementById('session-id-display');
520
+ const toast = document.getElementById('toast');
521
+ const toastMessage = document.getElementById('toast-message');
522
+
523
+ let tunnels = [];
524
+ let sessions = [];
525
+ let currentSessionId = localStorage.getItem('devtunnel-session-id');
526
+
527
+ // Initialize xterm.js
528
+ const term = new Terminal({
529
+ cursorBlink: true,
530
+ fontSize: 14,
531
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
532
+ theme: {
533
+ background: '#0d1117',
534
+ foreground: '#c9d1d9',
535
+ cursor: '#58a6ff',
536
+ cursorAccent: '#0d1117',
537
+ selection: 'rgba(56, 139, 253, 0.3)',
538
+ black: '#484f58',
539
+ red: '#ff7b72',
540
+ green: '#3fb950',
541
+ yellow: '#d29922',
542
+ blue: '#58a6ff',
543
+ magenta: '#bc8cff',
544
+ cyan: '#39c5cf',
545
+ white: '#b1bac4',
546
+ brightBlack: '#6e7681',
547
+ brightRed: '#ffa198',
548
+ brightGreen: '#56d364',
549
+ brightYellow: '#e3b341',
550
+ brightBlue: '#79c0ff',
551
+ brightMagenta: '#d2a8ff',
552
+ brightCyan: '#56d4dd',
553
+ brightWhite: '#f0f6fc'
554
+ }
555
+ });
556
+
557
+ const fitAddon = new FitAddon.FitAddon();
558
+ term.loadAddon(fitAddon);
559
+
560
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
561
+ term.loadAddon(webLinksAddon);
562
+
563
+ term.open(terminalContainer);
564
+ fitAddon.fit();
565
+
566
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
567
+ const wsUrl = `${protocol}//${window.location.host}`;
568
+ let ws;
569
+
570
+ function showToast(message, type = 'success') {
571
+ toastMessage.textContent = message;
572
+ toast.className = `toast show ${type}`;
573
+ setTimeout(() => {
574
+ toast.classList.remove('show');
575
+ }, 3000);
576
+ }
577
+
578
+ function formatTime(timestamp) {
579
+ const date = new Date(timestamp);
580
+ return date.toLocaleTimeString();
581
+ }
582
+
583
+ function renderSessions() {
584
+ const list = document.getElementById('session-list');
585
+ document.getElementById('session-count').textContent = sessions.length;
586
+
587
+ if (sessions.length === 0) {
588
+ list.innerHTML = '<div class="empty-state">No sessions</div>';
589
+ return;
590
+ }
591
+
592
+ list.innerHTML = sessions.map(session => `
593
+ <div class="item-card ${session.id === currentSessionId ? 'active' : ''}" onclick="attachToSession('${session.id}')">
594
+ <div class="item-card-header">
595
+ <span class="item-title">
596
+ ${session.id.slice(0, 8)}...
597
+ </span>
598
+ <div style="display: flex; gap: 4px; align-items: center;">
599
+ <span class="item-status ${session.alive ? 'active' : 'stopped'}">${session.alive ? 'alive' : 'ended'}</span>
600
+ <button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); killSession('${session.id}')">x</button>
601
+ </div>
602
+ </div>
603
+ <div class="item-meta">
604
+ ${session.clients} client(s) connected | Last: ${formatTime(session.lastAccess)}
605
+ </div>
606
+ </div>
607
+ `).join('');
608
+ }
609
+
610
+ function renderTunnels() {
611
+ const list = document.getElementById('tunnel-list');
612
+ document.getElementById('tunnel-count').textContent = tunnels.length;
613
+
614
+ if (tunnels.length === 0) {
615
+ list.innerHTML = '<div class="empty-state">No active tunnels</div>';
616
+ return;
617
+ }
618
+
619
+ list.innerHTML = tunnels.map(tunnel => `
620
+ <div class="item-card">
621
+ <div class="item-card-header">
622
+ <span class="item-title">Port ${tunnel.port}</span>
623
+ <div style="display: flex; gap: 4px; align-items: center;">
624
+ <span class="item-status ${tunnel.status}">${tunnel.status}</span>
625
+ <button class="btn btn-danger btn-sm" onclick="stopTunnel('${tunnel.id}')" ${tunnel.status !== 'active' ? 'disabled' : ''}>x</button>
626
+ </div>
627
+ </div>
628
+ ${tunnel.url ? `
629
+ <div class="item-url">
630
+ <input type="text" value="${tunnel.url}" readonly onclick="this.select()">
631
+ <button class="btn-copy" onclick="copyUrl('${tunnel.url}', this)">Copy</button>
632
+ </div>
633
+ ` : '<div class="item-meta">Connecting...</div>'}
634
+ </div>
635
+ `).join('');
636
+ }
637
+
638
+ function copyUrl(url, button) {
639
+ navigator.clipboard.writeText(url).then(() => {
640
+ button.textContent = 'Copied!';
641
+ button.classList.add('copied');
642
+ setTimeout(() => {
643
+ button.textContent = 'Copy';
644
+ button.classList.remove('copied');
645
+ }, 2000);
646
+ });
647
+ }
648
+
649
+ function createTunnel() {
650
+ const port = parseInt(portInput.value);
651
+ if (!port || port < 1 || port > 65535) {
652
+ showToast('Please enter a valid port (1-65535)', 'error');
653
+ return;
654
+ }
655
+
656
+ createTunnelBtn.disabled = true;
657
+ createTunnelBtn.textContent = 'Creating...';
658
+
659
+ ws.send(JSON.stringify({ type: 'create-tunnel', port }));
660
+ portInput.value = '';
661
+ }
662
+
663
+ function stopTunnel(id) {
664
+ ws.send(JSON.stringify({ type: 'stop-tunnel', id }));
665
+ }
666
+
667
+ function createNewSession() {
668
+ currentSessionId = null;
669
+ localStorage.removeItem('devtunnel-session-id');
670
+ term.clear();
671
+ ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
672
+ }
673
+
674
+ function attachToSession(sessionId) {
675
+ if (sessionId === currentSessionId) return;
676
+ currentSessionId = sessionId;
677
+ localStorage.setItem('devtunnel-session-id', sessionId);
678
+ term.clear();
679
+ ws.send(JSON.stringify({ type: 'attach', sessionId }));
680
+ }
681
+
682
+ function killSession(sessionId) {
683
+ ws.send(JSON.stringify({ type: 'kill-session', sessionId }));
684
+ }
685
+
686
+ function toggleSection(section) {
687
+ const content = document.getElementById(`${section}-content`);
688
+ content.style.display = content.style.display === 'none' ? 'block' : 'none';
689
+ }
690
+
691
+ // Make functions globally available
692
+ window.copyUrl = copyUrl;
693
+ window.stopTunnel = stopTunnel;
694
+ window.createNewSession = createNewSession;
695
+ window.attachToSession = attachToSession;
696
+ window.killSession = killSession;
697
+ window.toggleSection = toggleSection;
698
+
699
+ function connect() {
700
+ ws = new WebSocket(wsUrl);
701
+
702
+ ws.onopen = () => {
703
+ console.log('WebSocket connected');
704
+ statusDot.classList.add('connected');
705
+ statusText.textContent = 'Connected';
706
+ reconnectOverlay.classList.remove('show');
707
+
708
+ // Attach to existing or new session
709
+ ws.send(JSON.stringify({ type: 'attach', sessionId: currentSessionId }));
710
+ };
711
+
712
+ ws.onmessage = (event) => {
713
+ try {
714
+ const msg = JSON.parse(event.data);
715
+
716
+ switch (msg.type) {
717
+ case 'attached':
718
+ currentSessionId = msg.sessionId;
719
+ localStorage.setItem('devtunnel-session-id', msg.sessionId);
720
+ sessionIndicator.style.display = 'flex';
721
+ sessionIdDisplay.textContent = msg.sessionId.slice(0, 8);
722
+
723
+ // Send initial size
724
+ ws.send(JSON.stringify({
725
+ type: 'resize',
726
+ cols: term.cols,
727
+ rows: term.rows
728
+ }));
729
+
730
+ showToast('Session attached');
731
+ break;
732
+
733
+ case 'output':
734
+ term.write(msg.data);
735
+ break;
736
+
737
+ case 'exit':
738
+ term.write('\r\n\x1b[31mSession ended.\x1b[0m\r\n');
739
+ break;
740
+
741
+ case 'sessions':
742
+ sessions = msg.data;
743
+ renderSessions();
744
+ break;
745
+
746
+ case 'tunnels':
747
+ tunnels = msg.data;
748
+ renderTunnels();
749
+ break;
750
+
751
+ case 'tunnel-created':
752
+ showToast(`Tunnel created for port ${msg.data.port}`);
753
+ createTunnelBtn.disabled = false;
754
+ createTunnelBtn.textContent = 'Expose';
755
+ break;
756
+
757
+ case 'tunnel-error':
758
+ showToast(msg.error, 'error');
759
+ createTunnelBtn.disabled = false;
760
+ createTunnelBtn.textContent = 'Expose';
761
+ break;
762
+
763
+ case 'error':
764
+ showToast(msg.message, 'error');
765
+ break;
766
+ }
767
+ } catch (err) {
768
+ console.error('Error parsing message:', err);
769
+ }
770
+ };
771
+
772
+ ws.onclose = () => {
773
+ console.log('WebSocket disconnected');
774
+ statusDot.classList.remove('connected');
775
+ statusText.textContent = 'Disconnected';
776
+ reconnectOverlay.classList.add('show');
777
+ };
778
+
779
+ ws.onerror = (error) => {
780
+ console.error('WebSocket error:', error);
781
+ };
782
+ }
783
+
784
+ term.onData((data) => {
785
+ if (ws && ws.readyState === WebSocket.OPEN) {
786
+ ws.send(JSON.stringify({ type: 'input', data }));
787
+ }
788
+ });
789
+
790
+ function handleResize() {
791
+ fitAddon.fit();
792
+ if (ws && ws.readyState === WebSocket.OPEN) {
793
+ ws.send(JSON.stringify({
794
+ type: 'resize',
795
+ cols: term.cols,
796
+ rows: term.rows
797
+ }));
798
+ }
799
+ }
800
+
801
+ window.addEventListener('resize', handleResize);
802
+
803
+ createTunnelBtn.addEventListener('click', createTunnel);
804
+ portInput.addEventListener('keypress', (e) => {
805
+ if (e.key === 'Enter') {
806
+ createTunnel();
807
+ }
808
+ });
809
+
810
+ connect();
811
+ term.focus();
812
+ </script>
813
+ </body>
814
+ </html>