@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.
package/server.js ADDED
@@ -0,0 +1,458 @@
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const WebSocket = require('ws');
4
+ const pty = require('@homebridge/node-pty-prebuilt-multiarch');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { spawn } = require('child_process');
8
+ const crypto = require('crypto');
9
+
10
+ const app = express();
11
+ const server = http.createServer(app);
12
+ const wss = new WebSocket.Server({ server });
13
+
14
+ app.use(express.json());
15
+ app.use(express.static(path.join(__dirname, 'public')));
16
+
17
+ // Session configuration
18
+ const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours
19
+ const OUTPUT_BUFFER_SIZE = 50000; // Keep last 50KB of output
20
+
21
+ // Store persistent terminal sessions
22
+ const sessions = new Map();
23
+
24
+ // Store active tunnels
25
+ const tunnels = new Map();
26
+
27
+ // Generate session ID
28
+ function generateSessionId() {
29
+ return crypto.randomBytes(16).toString('hex');
30
+ }
31
+
32
+ // Create or get existing session
33
+ function getOrCreateSession(sessionId = null) {
34
+ // If sessionId provided and exists, return it
35
+ if (sessionId && sessions.has(sessionId)) {
36
+ const session = sessions.get(sessionId);
37
+ session.lastAccess = Date.now();
38
+ return session;
39
+ }
40
+
41
+ // Create new session
42
+ const id = sessionId || generateSessionId();
43
+ const shell = os.platform() === 'win32' ? 'powershell.exe' : '/bin/zsh';
44
+
45
+ const ptyProcess = pty.spawn(shell, [], {
46
+ name: 'xterm-256color',
47
+ cols: 80,
48
+ rows: 24,
49
+ cwd: process.env.WORKSPACE || os.homedir(),
50
+ env: { ...process.env, TERM: 'xterm-256color' }
51
+ });
52
+
53
+ const session = {
54
+ id,
55
+ pty: ptyProcess,
56
+ outputBuffer: '',
57
+ clients: new Set(),
58
+ lastAccess: Date.now(),
59
+ createdAt: Date.now(),
60
+ alive: true
61
+ };
62
+
63
+ // Buffer output for reconnection
64
+ ptyProcess.onData((data) => {
65
+ session.outputBuffer += data;
66
+ // Trim buffer if too large
67
+ if (session.outputBuffer.length > OUTPUT_BUFFER_SIZE) {
68
+ session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_SIZE);
69
+ }
70
+
71
+ // Send to all connected clients
72
+ session.clients.forEach(ws => {
73
+ if (ws.readyState === WebSocket.OPEN) {
74
+ ws.send(JSON.stringify({ type: 'output', data }));
75
+ }
76
+ });
77
+ });
78
+
79
+ ptyProcess.onExit(({ exitCode, signal }) => {
80
+ console.log(`Session ${id} terminal exited with code ${exitCode}`);
81
+ session.alive = false;
82
+
83
+ session.clients.forEach(ws => {
84
+ if (ws.readyState === WebSocket.OPEN) {
85
+ ws.send(JSON.stringify({ type: 'exit', exitCode, signal }));
86
+ }
87
+ });
88
+
89
+ // Remove session after a delay
90
+ setTimeout(() => {
91
+ sessions.delete(id);
92
+ broadcastSessionList();
93
+ }, 5000);
94
+ });
95
+
96
+ sessions.set(id, session);
97
+ console.log(`Session created: ${id}`);
98
+ return session;
99
+ }
100
+
101
+ // Clean up inactive sessions
102
+ setInterval(() => {
103
+ const now = Date.now();
104
+ sessions.forEach((session, id) => {
105
+ if (session.clients.size === 0 && now - session.lastAccess > SESSION_TIMEOUT) {
106
+ console.log(`Session ${id} timed out, cleaning up`);
107
+ if (session.pty) {
108
+ session.pty.kill();
109
+ }
110
+ sessions.delete(id);
111
+ }
112
+ });
113
+ }, 60000); // Check every minute
114
+
115
+ // Broadcast session list to all clients
116
+ function broadcastSessionList() {
117
+ const sessionList = Array.from(sessions.values()).map(s => ({
118
+ id: s.id,
119
+ createdAt: s.createdAt,
120
+ lastAccess: s.lastAccess,
121
+ alive: s.alive,
122
+ clients: s.clients.size
123
+ }));
124
+
125
+ wss.clients.forEach(client => {
126
+ if (client.readyState === WebSocket.OPEN) {
127
+ client.send(JSON.stringify({ type: 'sessions', data: sessionList }));
128
+ }
129
+ });
130
+ }
131
+
132
+ // Broadcast tunnel updates to all connected clients
133
+ function broadcastTunnelUpdate() {
134
+ const tunnelList = Array.from(tunnels.values()).map(t => ({
135
+ id: t.id,
136
+ port: t.port,
137
+ url: t.url,
138
+ status: t.status
139
+ }));
140
+
141
+ wss.clients.forEach(client => {
142
+ if (client.readyState === WebSocket.OPEN) {
143
+ client.send(JSON.stringify({ type: 'tunnels', data: tunnelList }));
144
+ }
145
+ });
146
+ }
147
+
148
+ // Create a new tunnel
149
+ function createTunnel(port) {
150
+ return new Promise((resolve, reject) => {
151
+ const id = `tunnel-${port}-${Date.now()}`;
152
+
153
+ for (const tunnel of tunnels.values()) {
154
+ if (tunnel.port === port && tunnel.status === 'active') {
155
+ reject(new Error(`Tunnel already exists for port ${port}`));
156
+ return;
157
+ }
158
+ }
159
+
160
+ const tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
161
+ stdio: ['ignore', 'pipe', 'pipe']
162
+ });
163
+
164
+ const tunnel = {
165
+ id,
166
+ port,
167
+ url: null,
168
+ status: 'connecting',
169
+ process: tunnelProcess
170
+ };
171
+
172
+ tunnels.set(id, tunnel);
173
+ broadcastTunnelUpdate();
174
+
175
+ let urlFound = false;
176
+
177
+ const handleOutput = (data) => {
178
+ const output = data.toString();
179
+ console.log(`[Tunnel ${port}] ${output}`);
180
+
181
+ const urlMatch = output.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
182
+ if (urlMatch && !urlFound) {
183
+ urlFound = true;
184
+ tunnel.url = urlMatch[0];
185
+ tunnel.status = 'active';
186
+ broadcastTunnelUpdate();
187
+ resolve(tunnel);
188
+ }
189
+ };
190
+
191
+ tunnelProcess.stdout.on('data', handleOutput);
192
+ tunnelProcess.stderr.on('data', handleOutput);
193
+
194
+ tunnelProcess.on('error', (err) => {
195
+ console.error(`Tunnel process error: ${err.message}`);
196
+ tunnel.status = 'error';
197
+ broadcastTunnelUpdate();
198
+ if (!urlFound) {
199
+ reject(err);
200
+ }
201
+ });
202
+
203
+ tunnelProcess.on('exit', (code) => {
204
+ console.log(`Tunnel for port ${port} exited with code ${code}`);
205
+ tunnel.status = 'stopped';
206
+ broadcastTunnelUpdate();
207
+
208
+ setTimeout(() => {
209
+ tunnels.delete(id);
210
+ broadcastTunnelUpdate();
211
+ }, 5000);
212
+ });
213
+
214
+ setTimeout(() => {
215
+ if (!urlFound) {
216
+ tunnel.status = 'error';
217
+ broadcastTunnelUpdate();
218
+ reject(new Error('Timeout waiting for tunnel URL'));
219
+ }
220
+ }, 30000);
221
+ });
222
+ }
223
+
224
+ function stopTunnel(id) {
225
+ const tunnel = tunnels.get(id);
226
+ if (tunnel && tunnel.process) {
227
+ tunnel.process.kill();
228
+ tunnel.status = 'stopping';
229
+ broadcastTunnelUpdate();
230
+ return true;
231
+ }
232
+ return false;
233
+ }
234
+
235
+ // WebSocket handling
236
+ wss.on('connection', (ws) => {
237
+ console.log('Client connected');
238
+
239
+ let currentSession = null;
240
+
241
+ // Send current tunnel list
242
+ const tunnelList = Array.from(tunnels.values()).map(t => ({
243
+ id: t.id,
244
+ port: t.port,
245
+ url: t.url,
246
+ status: t.status
247
+ }));
248
+ ws.send(JSON.stringify({ type: 'tunnels', data: tunnelList }));
249
+
250
+ // Send session list
251
+ const sessionList = Array.from(sessions.values()).map(s => ({
252
+ id: s.id,
253
+ createdAt: s.createdAt,
254
+ lastAccess: s.lastAccess,
255
+ alive: s.alive,
256
+ clients: s.clients.size
257
+ }));
258
+ ws.send(JSON.stringify({ type: 'sessions', data: sessionList }));
259
+
260
+ ws.on('message', (message) => {
261
+ try {
262
+ const msg = JSON.parse(message.toString());
263
+
264
+ switch (msg.type) {
265
+ case 'attach':
266
+ // Attach to existing or new session
267
+ try {
268
+ currentSession = getOrCreateSession(msg.sessionId);
269
+ currentSession.clients.add(ws);
270
+
271
+ // Send session info
272
+ ws.send(JSON.stringify({
273
+ type: 'attached',
274
+ sessionId: currentSession.id,
275
+ alive: currentSession.alive
276
+ }));
277
+
278
+ // Send buffered output
279
+ if (currentSession.outputBuffer) {
280
+ ws.send(JSON.stringify({
281
+ type: 'output',
282
+ data: currentSession.outputBuffer
283
+ }));
284
+ }
285
+
286
+ broadcastSessionList();
287
+ console.log(`Client attached to session ${currentSession.id}`);
288
+ } catch (err) {
289
+ ws.send(JSON.stringify({
290
+ type: 'error',
291
+ message: err.message
292
+ }));
293
+ }
294
+ break;
295
+
296
+ case 'input':
297
+ if (currentSession && currentSession.pty && currentSession.alive) {
298
+ currentSession.pty.write(msg.data);
299
+ }
300
+ break;
301
+
302
+ case 'resize':
303
+ if (currentSession && currentSession.pty && msg.cols && msg.rows) {
304
+ currentSession.pty.resize(msg.cols, msg.rows);
305
+ }
306
+ break;
307
+
308
+ case 'create-tunnel':
309
+ if (msg.port) {
310
+ createTunnel(msg.port)
311
+ .then(tunnel => {
312
+ ws.send(JSON.stringify({
313
+ type: 'tunnel-created',
314
+ data: { id: tunnel.id, port: tunnel.port, url: tunnel.url }
315
+ }));
316
+ })
317
+ .catch(err => {
318
+ ws.send(JSON.stringify({
319
+ type: 'tunnel-error',
320
+ error: err.message
321
+ }));
322
+ });
323
+ }
324
+ break;
325
+
326
+ case 'stop-tunnel':
327
+ if (msg.id) {
328
+ stopTunnel(msg.id);
329
+ }
330
+ break;
331
+
332
+ case 'kill-session':
333
+ if (msg.sessionId) {
334
+ const session = sessions.get(msg.sessionId);
335
+ if (session && session.pty) {
336
+ session.pty.kill();
337
+ }
338
+ }
339
+ break;
340
+
341
+ default:
342
+ console.log('Unknown message type:', msg.type);
343
+ }
344
+ } catch (err) {
345
+ console.error('Error processing message:', err);
346
+ }
347
+ });
348
+
349
+ ws.on('close', () => {
350
+ console.log('Client disconnected');
351
+ if (currentSession) {
352
+ currentSession.clients.delete(ws);
353
+ currentSession.lastAccess = Date.now();
354
+ broadcastSessionList();
355
+ // Note: We don't kill the session here!
356
+ }
357
+ });
358
+
359
+ ws.on('error', (err) => {
360
+ console.error('WebSocket error:', err);
361
+ if (currentSession) {
362
+ currentSession.clients.delete(ws);
363
+ }
364
+ });
365
+ });
366
+
367
+ // REST API
368
+ app.get('/api/sessions', (req, res) => {
369
+ const sessionList = Array.from(sessions.values()).map(s => ({
370
+ id: s.id,
371
+ createdAt: s.createdAt,
372
+ lastAccess: s.lastAccess,
373
+ alive: s.alive,
374
+ clients: s.clients.size
375
+ }));
376
+ res.json(sessionList);
377
+ });
378
+
379
+ app.delete('/api/sessions/:id', (req, res) => {
380
+ const session = sessions.get(req.params.id);
381
+ if (session) {
382
+ if (session.pty) {
383
+ session.pty.kill();
384
+ }
385
+ sessions.delete(req.params.id);
386
+ broadcastSessionList();
387
+ res.json({ success: true });
388
+ } else {
389
+ res.status(404).json({ error: 'Session not found' });
390
+ }
391
+ });
392
+
393
+ app.get('/api/tunnels', (req, res) => {
394
+ const tunnelList = Array.from(tunnels.values()).map(t => ({
395
+ id: t.id,
396
+ port: t.port,
397
+ url: t.url,
398
+ status: t.status
399
+ }));
400
+ res.json(tunnelList);
401
+ });
402
+
403
+ app.post('/api/tunnels', async (req, res) => {
404
+ const { port } = req.body;
405
+ if (!port || typeof port !== 'number') {
406
+ return res.status(400).json({ error: 'Valid port number required' });
407
+ }
408
+
409
+ try {
410
+ const tunnel = await createTunnel(port);
411
+ res.json({ id: tunnel.id, port: tunnel.port, url: tunnel.url });
412
+ } catch (err) {
413
+ res.status(500).json({ error: err.message });
414
+ }
415
+ });
416
+
417
+ app.delete('/api/tunnels/:id', (req, res) => {
418
+ if (stopTunnel(req.params.id)) {
419
+ res.json({ success: true });
420
+ } else {
421
+ res.status(404).json({ error: 'Tunnel not found' });
422
+ }
423
+ });
424
+
425
+ app.get('/health', (req, res) => {
426
+ res.json({
427
+ status: 'ok',
428
+ sessions: sessions.size,
429
+ tunnels: tunnels.size
430
+ });
431
+ });
432
+
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}`);
436
+ });
437
+
438
+ // Graceful shutdown
439
+ process.on('SIGINT', () => {
440
+ console.log('\nShutting down...');
441
+
442
+ tunnels.forEach((tunnel) => {
443
+ if (tunnel.process) {
444
+ tunnel.process.kill();
445
+ }
446
+ });
447
+
448
+ sessions.forEach((session) => {
449
+ if (session.pty) {
450
+ session.pty.kill();
451
+ }
452
+ });
453
+
454
+ server.close(() => {
455
+ console.log('Server closed');
456
+ process.exit(0);
457
+ });
458
+ });